Skip to content

Commit

Permalink
feat(project): use a native fallback for image service
Browse files Browse the repository at this point in the history
  • Loading branch information
AntonLantukh committed Jul 5, 2023
1 parent 2d8e4be commit b4039d9
Show file tree
Hide file tree
Showing 20 changed files with 55 additions and 297 deletions.
25 changes: 1 addition & 24 deletions src/components/Card/Card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Card from './Card';
import type { PlaylistItem } from '#types/playlist';

const item = { title: 'aa', duration: 120 } as PlaylistItem;
const itemWithImage = { title: 'This is a movie', duration: 120, shelfImage: { image: 'http://movie.jpg' } } as PlaylistItem;
const itemWithImage = { title: 'This is a movie', duration: 120, shelfImage: 'http://movie.jpg' } as PlaylistItem;

describe('<Card>', () => {
it('renders card with video title', () => {
Expand Down Expand Up @@ -35,27 +35,4 @@ describe('<Card>', () => {

expect(getByAltText('This is a movie')).toHaveStyle({ opacity: 1 });
});

it('uses the fallback image when the image fails to load', () => {
const itemWithFallbackImage = {
title: 'This is a movie',
duration: 120,
shelfImage: {
image: 'http://movie.jpg',
fallbackImage: 'http://fallback.jpg',
},
} as PlaylistItem;

const { getByAltText } = render(<Card item={itemWithFallbackImage} onClick={() => ''} />);

fireEvent.error(getByAltText('This is a movie'));

expect(getByAltText('This is a movie')).toHaveAttribute('src', 'http://fallback.jpg?width=320');
expect(getByAltText('This is a movie')).toHaveStyle({ opacity: 0 });

fireEvent.load(getByAltText('This is a movie'));

expect(getByAltText('This is a movie')).toHaveAttribute('src', 'http://fallback.jpg?width=320');
expect(getByAltText('This is a movie')).toHaveStyle({ opacity: 1 });
});
});
2 changes: 1 addition & 1 deletion src/components/EpgChannel/EpgChannelItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const EpgChannelItem: React.VFC<Props> = ({ channel, channelItemWidth, sidebarWi
onClick={() => onClick && onClick(channel)}
data-testid={testId(uuid)}
>
<Image className={styles.epgChannelLogo} image={channelLogoImage} alt="Logo" width={320} />
<Image className={styles.epgChannelLogo} image={channelLogoImage.image} alt="Logo" width={320} />
</div>
</div>
);
Expand Down
3 changes: 1 addition & 2 deletions src/components/Hero/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@ import React from 'react';
import styles from './Hero.module.scss';

import Image from '#components/Image/Image';
import type { ImageData } from '#types/playlist';

type Props = {
title: string;
description: string;
image?: ImageData;
image?: string;
};

const Hero = ({ title, description, image }: Props) => {
Expand Down
81 changes: 3 additions & 78 deletions src/components/Image/Image.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,97 +5,22 @@ import Image from './Image';

describe('<Image>', () => {
test('uses the src attribute when valid', () => {
const { getByAltText } = render(<Image image={{ image: 'http://image.jpg' }} alt="image" />);
const { getByAltText } = render(<Image image="http://image.jpg" alt="image" />);

expect(getByAltText('image')).toHaveAttribute('src', 'http://image.jpg?width=640');
});

test('tries the fallbackSrc when the image fails to load', () => {
const { getByAltText } = render(
<Image
image={{
image: 'http://image.jpg',
fallbackImage: 'http://fallback.jpg',
}}
alt="image"
/>,
);

fireEvent.error(getByAltText('image'));

expect(getByAltText('image')).toHaveAttribute('src', 'http://fallback.jpg?width=640');
});

test('updates the src attribute when changed', () => {
const { getByAltText, rerender } = render(<Image image={{ image: 'http://image.jpg' }} alt="image" />);

expect(getByAltText('image')).toHaveAttribute('src', 'http://image.jpg?width=640');

rerender(<Image image={{ image: 'http://otherimage.jpg' }} alt="image" />);

expect(getByAltText('image')).toHaveAttribute('src', 'http://otherimage.jpg?width=640');
});

test('updates the src attribute when changed with the fallback image', () => {
const { getByAltText, rerender } = render(<Image image={{ image: 'http://image.jpg' }} alt="image" />);

expect(getByAltText('image')).toHaveAttribute('src', 'http://image.jpg?width=640');

rerender(
<Image
image={{
image: 'http://otherimage.jpg',
fallbackImage: 'http://otherfallback.jpg',
}}
alt="image"
/>,
);

fireEvent.error(getByAltText('image'));

expect(getByAltText('image')).toHaveAttribute('src', 'http://otherfallback.jpg?width=640');
});

test('fires the onLoad callback when the image is loaded', () => {
const onLoad = vi.fn();
const { getByAltText } = render(<Image image={{ image: 'http://image.jpg' }} alt="image" onLoad={onLoad} />);

fireEvent.load(getByAltText('image'));

expect(onLoad).toHaveBeenCalledTimes(1);
});

test('fires the onLoad callback when the fallback image is loaded', () => {
const onLoad = vi.fn();
const { getByAltText } = render(
<Image
image={{
image: 'http://image.jpg',
fallbackImage: 'http://fallback.jpg',
}}
alt="image"
onLoad={onLoad}
/>,
);
const { getByAltText } = render(<Image image="http://image.jpg" alt="image" onLoad={onLoad} />);

fireEvent.error(getByAltText('image'));
fireEvent.load(getByAltText('image'));

expect(getByAltText('image')).toHaveAttribute('src', 'http://fallback.jpg?width=640');
expect(onLoad).toHaveBeenCalledTimes(1);
});

test('changes the image width based on the given width', () => {
const { getByAltText } = render(
<Image
image={{
image: 'http://image.jpg',
fallbackImage: 'http://fallback.jpg',
}}
alt="image"
width={1280}
/>,
);
const { getByAltText } = render(<Image image="http://image.jpg" alt="image" width={1280} />);

expect(getByAltText('image')).toHaveAttribute('src', 'http://image.jpg?width=1280');
});
Expand Down
21 changes: 4 additions & 17 deletions src/components/Image/Image.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import classNames from 'classnames';

import styles from './Image.module.scss';

import { addQueryParams } from '#src/utils/formatting';
import type { ImageData } from '#types/playlist';

type Props = {
className?: string;
image?: ImageData;
image?: string;
onLoad?: () => void;
alt?: string;
width?: number;
Expand All @@ -19,25 +18,13 @@ const setWidth = (url: string, width: number) => {
};

const Image = ({ className, image, onLoad, alt = '', width = 640 }: Props) => {
const [imgSrc, setImgSrc] = useState(image?.image);

const handleLoad = () => {
if (onLoad) onLoad();
};

const handleError = () => {
if (image?.fallbackImage && image.fallbackImage !== image.image) {
setImgSrc(image?.fallbackImage);
}
};

useEffect(() => {
setImgSrc(image?.image);
}, [image]);

if (!imgSrc) return null;
if (!image) return null;

return <img className={classNames(className, styles.image)} src={setWidth(imgSrc, width)} onLoad={handleLoad} onError={handleError} alt={alt} />;
return <img className={classNames(className, styles.image)} src={setWidth(image, width)} onLoad={handleLoad} alt={alt} />;
};

export default React.memo(Image);
28 changes: 3 additions & 25 deletions src/components/VideoDetails/VideoDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { render } from '@testing-library/react';

import VideoDetails from './VideoDetails';

Expand All @@ -11,7 +11,7 @@ describe('<VideoDetails>', () => {
description="Video description"
primaryMetadata="Primary metadata string"
secondaryMetadata={<strong>Secondary metadata string</strong>}
image={{ image: 'http://image.jpg' }}
image="http://image.jpg"
startWatchingButton={<button>Start watching</button>}
shareButton={<button>share</button>}
favoriteButton={<button>favorite</button>}
Expand All @@ -29,7 +29,7 @@ describe('<VideoDetails>', () => {
description="Video description"
primaryMetadata="Primary metadata string"
secondaryMetadata={<strong>Secondary metadata string</strong>}
image={{ image: 'http://image.jpg' }}
image="http://image.jpg"
startWatchingButton={<button>Start watching</button>}
shareButton={<button>share</button>}
favoriteButton={<button>favorite</button>}
Expand All @@ -39,26 +39,4 @@ describe('<VideoDetails>', () => {

expect(getByAltText('Test video')).toHaveAttribute('src', 'http://image.jpg?width=1280');
});

test('renders the fallback image when the image fails to load', () => {
const { getByAltText } = render(
<VideoDetails
title="Test video"
description="Video description"
primaryMetadata="Primary metadata string"
secondaryMetadata={<strong>Secondary metadata string</strong>}
image={{ image: 'http://image.jpg', fallbackImage: 'http://fallback.jpg' }}
startWatchingButton={<button>Start watching</button>}
shareButton={<button>share</button>}
favoriteButton={<button>favorite</button>}
trailerButton={<button>play trailer</button>}
/>,
);

expect(getByAltText('Test video')).toHaveAttribute('src', 'http://image.jpg?width=1280');

fireEvent.error(getByAltText('Test video'));

expect(getByAltText('Test video')).toHaveAttribute('src', 'http://fallback.jpg?width=1280');
});
});
3 changes: 1 addition & 2 deletions src/components/VideoDetails/VideoDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@ import styles from './VideoDetails.module.scss';
import CollapsibleText from '#components/CollapsibleText/CollapsibleText';
import useBreakpoint, { Breakpoint } from '#src/hooks/useBreakpoint';
import Image from '#components/Image/Image';
import type { ImageData } from '#types/playlist';
import { testId } from '#src/utils/common';

type Props = {
title: string;
description: string;
primaryMetadata: React.ReactNode;
secondaryMetadata?: React.ReactNode;
image?: ImageData;
image?: string;
startWatchingButton: React.ReactNode;
shareButton: React.ReactNode;
favoriteButton: React.ReactNode;
Expand Down
4 changes: 2 additions & 2 deletions src/components/VideoLayout/VideoLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import VideoDetailsInline from '#components/VideoDetailsInline/VideoDetailsInlin
import VideoList from '#components/VideoList/VideoList';
import useBreakpoint, { Breakpoint } from '#src/hooks/useBreakpoint';
import { testId } from '#src/utils/common';
import type { ImageData, Playlist, PlaylistItem } from '#types/playlist';
import type { Playlist, PlaylistItem } from '#types/playlist';
import type { AccessModel } from '#types/Config';

type FilterProps = {
Expand All @@ -29,7 +29,7 @@ type LoadMoreProps = {
type VideoDetailsProps = {
title: string;
description: string;
image?: ImageData;
image?: string;
primaryMetadata: React.ReactNode;
secondaryMetadata?: React.ReactNode;
shareButton: React.ReactNode;
Expand Down
4 changes: 2 additions & 2 deletions src/pages/LegacySeries/LegacySeries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import ShareButton from '#components/ShareButton/ShareButton';
import FavoriteButton from '#src/containers/FavoriteButton/FavoriteButton';
import Button from '#components/Button/Button';
import PlayTrailer from '#src/icons/PlayTrailer';
import type { PlaylistItem, ImageData } from '#types/playlist';
import type { PlaylistItem } from '#types/playlist';
import useQueryParam from '#src/hooks/useQueryParam';
import Loading from '#src/pages/Loading/Loading';
import usePlaylist from '#src/hooks/usePlaylist';
Expand Down Expand Up @@ -108,7 +108,7 @@ const LegacySeries = () => {
const pageTitle = `${selectedItem.title} - ${siteName}`;
const pageDescription = selectedItem?.description || '';
const canonicalUrl = `${window.location.origin}${legacySeriesURL({ episodeId: episode?.mediaid, seriesId })}`;
const backgroundImage = (selectedItem.backgroundImage as ImageData) || undefined;
const backgroundImage = (selectedItem.backgroundImage as string) || undefined;

const primaryMetadata = episode
? formatVideoMetaString(episode, t('video:total_episodes', { count: seriesPlaylist?.playlist?.length }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ const PlaylistLiveChannels: ScreenComponent<Playlist> = ({ data: { feedid, playl
description={videoDetails.description}
item={channelMediaItem}
primaryMetadata={primaryMetadata}
image={videoDetails.image}
image={videoDetails.image?.image}
startWatchingButton={startWatchingButton}
shareButton={shareButton}
trailerButton={null}
Expand Down
22 changes: 12 additions & 10 deletions src/services/api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,33 @@ import type { GetPlaylistParams, Playlist, PlaylistItem } from '#types/playlist'
import type { AdSchedule } from '#types/ad-schedule';
import type { EpisodesRes, EpisodesWithPagination, GetSeriesParams, Series, EpisodeInSeries } from '#types/series';
import { useConfigStore as ConfigStore } from '#src/stores/ConfigStore';
import { generateImageData } from '#src/utils/image';

// change the values below to change the property used to look up the alternate image
enum ImageProperty {
SHELF = 'shelfImage',
BACKGROUND = 'backgroundImage',
CHANNEL_LOGO = 'channelLogoImage',
CARD = 'card',
BACKGROUND = 'background',
CHANNEL_LOGO = 'channel_logo',
}

const PAGE_LIMIT = 20;

const generateAlternateImageURL = (item: PlaylistItem, label: string) =>
`https://img.jwplayer.com/v1/media/${item.mediaid}/images/${label}.webp?poster_fallback=1`;

/**
* Transform incoming media items
* - Parses productId into MediaOffer[] for all cleeng offers
*/
export const transformMediaItem = (item: PlaylistItem, playlist?: Playlist) => {
export const transformMediaItem = (item: PlaylistItem) => {
const config = ConfigStore.getState().config;

const offerKeys = Object.keys(config?.integrations)[0];

const transformedMediaItem = {
...item,
shelfImage: generateImageData(config, ImageProperty.SHELF, item, playlist),
backgroundImage: generateImageData(config, ImageProperty.BACKGROUND, item),
channelLogoImage: generateImageData(config, ImageProperty.CHANNEL_LOGO, item),
shelfImage: generateAlternateImageURL(item, ImageProperty.CARD),
backgroundImage: generateAlternateImageURL(item, ImageProperty.BACKGROUND),
channelLogoImage: generateAlternateImageURL(item, ImageProperty.CHANNEL_LOGO),
mediaOffers: item.productIds ? filterMediaOffers(offerKeys, item.productIds) : undefined,
scheduledStart: item['VCH.ScheduledStart'] ? parseISO(item['VCH.ScheduledStart'] as string) : undefined,
scheduledEnd: item['VCH.ScheduledEnd'] ? parseISO(item['VCH.ScheduledEnd'] as string) : undefined,
Expand All @@ -52,7 +54,7 @@ export const transformMediaItem = (item: PlaylistItem, playlist?: Playlist) => {
* @param relatedMediaId
*/
export const transformPlaylist = (playlist: Playlist, relatedMediaId?: string) => {
playlist.playlist = playlist.playlist.map((item) => transformMediaItem(item, playlist));
playlist.playlist = playlist.playlist.map((item) => transformMediaItem(item));

// remove the related media item (when this is a recommendations playlist)
if (relatedMediaId) playlist.playlist.filter((item) => item.mediaid !== relatedMediaId);
Expand Down Expand Up @@ -96,7 +98,7 @@ export const getMediaByWatchlist = async (playlistId: string, mediaIds: string[]

if (!data) throw new Error(`The data was not found using the watchlist ${playlistId}`);

return (data.playlist || []).map((item) => transformMediaItem(item, data));
return (data.playlist || []).map((item) => transformMediaItem(item));
};

/**
Expand Down
Loading

0 comments on commit b4039d9

Please sign in to comment.