diff --git a/frontend/src/Movie/Movie.ts b/frontend/src/Movie/Movie.ts
index cf5d7de63..2d87bd53e 100644
--- a/frontend/src/Movie/Movie.ts
+++ b/frontend/src/Movie/Movie.ts
@@ -9,8 +9,10 @@ export type MovieStatus =
| 'released'
| 'deleted';
+export type CoverType = 'poster' | 'fanart';
+
export interface Image {
- coverType: string;
+ coverType: CoverType;
url: string;
remoteUrl: string;
}
diff --git a/frontend/src/Movie/MovieImage.js b/frontend/src/Movie/MovieImage.js
deleted file mode 100644
index 667027c0b..000000000
--- a/frontend/src/Movie/MovieImage.js
+++ /dev/null
@@ -1,198 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import LazyLoad from 'react-lazyload';
-
-function findImage(images, coverType) {
- return images.find((image) => image.coverType === coverType);
-}
-
-function getUrl(image, coverType, size) {
- const imageUrl = image?.url ?? image?.remoteUrl;
-
- if (imageUrl) {
- return imageUrl.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`);
- }
-}
-
-class MovieImage extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- const pixelRatio = Math.ceil(window.devicePixelRatio);
-
- const {
- images,
- coverType,
- size
- } = props;
-
- const image = findImage(images, coverType);
-
- this.state = {
- pixelRatio,
- image,
- url: getUrl(image, coverType, pixelRatio * size),
- isLoaded: false,
- hasError: false
- };
- }
-
- componentDidMount() {
- if (!this.state.url && this.props.onError) {
- this.props.onError();
- }
- }
-
- componentDidUpdate() {
- const {
- images,
- coverType,
- placeholder,
- size,
- onError
- } = this.props;
-
- const {
- image,
- pixelRatio
- } = this.state;
-
- const nextImage = findImage(images, coverType);
-
- if (nextImage && (!image || nextImage.url !== image.url)) {
- this.setState({
- image: nextImage,
- url: getUrl(nextImage, coverType, pixelRatio * size),
- hasError: false
- // Don't reset isLoaded, as we want to immediately try to
- // show the new image, whether an image was shown previously
- // or the placeholder was shown.
- });
- } else if (!nextImage && image) {
- this.setState({
- image: nextImage,
- url: placeholder,
- hasError: false
- });
-
- if (onError) {
- onError();
- }
- }
- }
-
- //
- // Listeners
-
- onError = () => {
- this.setState({
- hasError: true
- });
-
- if (this.props.onError) {
- this.props.onError();
- }
- };
-
- onLoad = () => {
- this.setState({
- isLoaded: true,
- hasError: false
- });
-
- if (this.props.onLoad) {
- this.props.onLoad();
- }
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- style,
- placeholder,
- size,
- lazy,
- overflow
- } = this.props;
-
- const {
- url,
- hasError,
- isLoaded
- } = this.state;
-
- if (hasError || !url) {
- return (
-
- );
- }
-
- if (lazy) {
- return (
-
- }
- >
-
-
- );
- }
-
- return (
-
- );
- }
-}
-
-MovieImage.propTypes = {
- className: PropTypes.string,
- style: PropTypes.object,
- images: PropTypes.arrayOf(PropTypes.object).isRequired,
- coverType: PropTypes.string.isRequired,
- placeholder: PropTypes.string.isRequired,
- size: PropTypes.number.isRequired,
- lazy: PropTypes.bool.isRequired,
- overflow: PropTypes.bool.isRequired,
- onError: PropTypes.func,
- onLoad: PropTypes.func
-};
-
-MovieImage.defaultProps = {
- size: 250,
- lazy: true,
- overflow: false
-};
-
-export default MovieImage;
diff --git a/frontend/src/Movie/MovieImage.tsx b/frontend/src/Movie/MovieImage.tsx
new file mode 100644
index 000000000..9c3261de8
--- /dev/null
+++ b/frontend/src/Movie/MovieImage.tsx
@@ -0,0 +1,128 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import LazyLoad from 'react-lazyload';
+import { CoverType, Image } from './Movie';
+
+function findImage(images: Image[], coverType: CoverType) {
+ return images.find((image) => image.coverType === coverType);
+}
+
+function getUrl(image: Image, coverType: CoverType, size: number) {
+ const imageUrl = image?.url ?? image?.remoteUrl;
+
+ return imageUrl
+ ? imageUrl.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`)
+ : null;
+}
+
+export interface MovieImageProps {
+ className?: string;
+ style?: object;
+ images: Image[];
+ coverType: CoverType;
+ placeholder: string;
+ size?: number;
+ lazy?: boolean;
+ overflow?: boolean;
+ onError?: () => void;
+ onLoad?: () => void;
+}
+
+const pixelRatio = Math.max(Math.round(window.devicePixelRatio), 1);
+
+function MovieImage({
+ className,
+ style,
+ images,
+ coverType,
+ placeholder,
+ size = 250,
+ lazy = true,
+ overflow = false,
+ onError,
+ onLoad,
+}: MovieImageProps) {
+ const [url, setUrl] = useState(null);
+ const [hasError, setHasError] = useState(false);
+ const [isLoaded, setIsLoaded] = useState(false);
+ const image = useRef(null);
+
+ const handleLoad = useCallback(() => {
+ setHasError(false);
+ setIsLoaded(true);
+ onLoad?.();
+ }, [setHasError, setIsLoaded, onLoad]);
+
+ const handleError = useCallback(() => {
+ setHasError(true);
+ setIsLoaded(false);
+ onError?.();
+ }, [setHasError, setIsLoaded, onError]);
+
+ useEffect(() => {
+ const nextImage = findImage(images, coverType);
+
+ if (nextImage && (!image.current || nextImage.url !== image.current.url)) {
+ // Don't reset isLoaded, as we want to immediately try to
+ // show the new image, whether an image was shown previously
+ // or the placeholder was shown.
+ image.current = nextImage;
+
+ setUrl(getUrl(nextImage, coverType, pixelRatio * size));
+ setHasError(false);
+ } else if (!nextImage) {
+ if (image.current) {
+ image.current = null;
+ setUrl(placeholder);
+ setHasError(false);
+ onError?.();
+ }
+ }
+ }, [images, coverType, placeholder, size, onError]);
+
+ useEffect(() => {
+ if (!image.current) {
+ onError?.();
+ }
+ // This should only run once when the component mounts,
+ // so we don't need to include the other dependencies.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ if (hasError || !url) {
+ return ;
+ }
+
+ if (lazy) {
+ return (
+
+ }
+ >
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+export default MovieImage;
diff --git a/frontend/src/Movie/MoviePoster.js b/frontend/src/Movie/MoviePoster.js
deleted file mode 100644
index b0f89a828..000000000
--- a/frontend/src/Movie/MoviePoster.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import MovieImage from './MovieImage';
-
-const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3CAMAAAC+Te+kAAAAZlBMVEUvLi8vLy8vLzAvMDAwLy8wLzAwMDAwMDEwMTExMDAxMDExMTExMTIxMjIyMjIyMjMyMzMzMjMzMzMzMzQzNDQ0NDQ0NDU0NTU1NTU1NTY1NjY2NTY2NjY2Njc2Nzc3Njc3Nzc3NziHChLWAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+MKCgEdHeShUbsAAALZSURBVHja7dxNcuwgDEZR1qAVmP1vMrNUJe91GfTzCSpXo575lAymjYWGXRIDKFCgQIECBQoUKFCgQIECBQoUKFCgQIECBQoUKFCgQIECBQoUKNA/AZ3fcTR0/owjofNDnAadnwPoPnS+xTXQeQZ0rkQ/dC4H0Gzo7ITO3bgGOnug/2PcAF3Mczt0fUj0QncG7znQBupw3PkWqh8qpkagpnyqjuArkkxaC02kRqGypCZANVYFdJZCdy9WTRVB5znQ6qTmjFFBWnOhdg20Lqnp0CpqAbRmAJRAK5JaA32zngTNvv910OSkVkJTs1oLtWugeTkNQZ/nkT2rotBHldUwNE6VQTVWGTQ6AHKggqGaBS23JkKf0hUgE1qa01Ro5fzPhoapR0HtCGg4q0poSCqFRgaAFhqxqqEr1EOgmdJaqHdaHQq1I6CunPZAHdY2aIJUBN2V9kE3H1Wd0BXrNVA7BLpgdUCtALo8pZqhdgd0Z6OyE7q1pdoH3dv7tS7o7iZ1E3R/N70Huuz795cQao65vvkqooT+vEgDdPcbj2s3zxTv9Qt/7cuhdgfUo2yAOplyqNuphfqZSqhFmEJo0HkcdPZCo0rRymRxpwSawHR+YtyBZihfvi+nQO0OqCmcYahGqYPGS4qCUJkzBpUpJdCkordyaFZxXi1UUpaZAJ2XQFOLh8ug2XXjVdD0+vYiqLIO3w1VH8EogtoxUPnpGxe04zyTA1p57i4T2nTmbnnnUuLMg1afYE2C1h+1zYEKjlknQLtPg9tb3YzU+dL054qOBb8cvcz3DlqBZhUmhdrnKo9j+pR0rkN5UHkznZHPtJIYN2TTCe1poTUyk9nWPO0bt8Ys7Ug34mlUMONtPUXMaEdXnXN1MnUzN2Z9q3Lr8XQN1DaLQJpXpiamZwltYdIUHShQoECBAgUKFChQoECBAgUKFChQoECBAgUKFChQoECBAgUKFCjQ+vgCff/mEp/vtiIAAAAASUVORK5CYII=';
-
-function MoviePoster(props) {
- return (
-
- );
-}
-
-MoviePoster.propTypes = {
- ...MovieImage.propTypes,
- coverType: PropTypes.string,
- placeholder: PropTypes.string,
- overflow: PropTypes.bool,
- size: PropTypes.number.isRequired
-};
-
-MoviePoster.defaultProps = {
- ...MovieImage.defaultProps,
- size: 250
-};
-
-export default MoviePoster;
diff --git a/frontend/src/Movie/MoviePoster.tsx b/frontend/src/Movie/MoviePoster.tsx
new file mode 100644
index 000000000..10a9e3605
--- /dev/null
+++ b/frontend/src/Movie/MoviePoster.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import MovieImage, { MovieImageProps } from './MovieImage';
+
+const posterPlaceholder =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3CAMAAAC+Te+kAAAAZlBMVEUvLi8vLy8vLzAvMDAwLy8wLzAwMDAwMDEwMTExMDAxMDExMTExMTIxMjIyMjIyMjMyMzMzMjMzMzMzMzQzNDQ0NDQ0NDU0NTU1NTU1NTY1NjY2NTY2NjY2Njc2Nzc3Njc3Nzc3NziHChLWAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+MKCgEdHeShUbsAAALZSURBVHja7dxNcuwgDEZR1qAVmP1vMrNUJe91GfTzCSpXo575lAymjYWGXRIDKFCgQIECBQoUKFCgQIECBQoUKFCgQIECBQoUKFCgQIECBQoUKNA/AZ3fcTR0/owjofNDnAadnwPoPnS+xTXQeQZ0rkQ/dC4H0Gzo7ITO3bgGOnug/2PcAF3Mczt0fUj0QncG7znQBupw3PkWqh8qpkagpnyqjuArkkxaC02kRqGypCZANVYFdJZCdy9WTRVB5znQ6qTmjFFBWnOhdg20Lqnp0CpqAbRmAJRAK5JaA32zngTNvv910OSkVkJTs1oLtWugeTkNQZ/nkT2rotBHldUwNE6VQTVWGTQ6AHKggqGaBS23JkKf0hUgE1qa01Ro5fzPhoapR0HtCGg4q0poSCqFRgaAFhqxqqEr1EOgmdJaqHdaHQq1I6CunPZAHdY2aIJUBN2V9kE3H1Wd0BXrNVA7BLpgdUCtALo8pZqhdgd0Z6OyE7q1pdoH3dv7tS7o7iZ1E3R/N70Huuz795cQao65vvkqooT+vEgDdPcbj2s3zxTv9Qt/7cuhdgfUo2yAOplyqNuphfqZSqhFmEJo0HkcdPZCo0rRymRxpwSawHR+YtyBZihfvi+nQO0OqCmcYahGqYPGS4qCUJkzBpUpJdCkordyaFZxXi1UUpaZAJ2XQFOLh8ug2XXjVdD0+vYiqLIO3w1VH8EogtoxUPnpGxe04zyTA1p57i4T2nTmbnnnUuLMg1afYE2C1h+1zYEKjlknQLtPg9tb3YzU+dL054qOBb8cvcz3DlqBZhUmhdrnKo9j+pR0rkN5UHkznZHPtJIYN2TTCe1poTUyk9nWPO0bt8Ys7Ug34mlUMONtPUXMaEdXnXN1MnUzN2Z9q3Lr8XQN1DaLQJpXpiamZwltYdIUHShQoECBAgUKFChQoECBAgUKFChQoECBAgUKFChQoECBAgUKFCjQ+vgCff/mEp/vtiIAAAAASUVORK5CYII=';
+
+interface MoviePosterProps
+ extends Omit {
+ size?: 250 | 500;
+}
+
+function MoviePoster({ size = 250, ...otherProps }: MoviePosterProps) {
+ return (
+
+ );
+}
+
+export default MoviePoster;