Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 36 additions & 15 deletions src/components/media/ContinueWatchingCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { memo } from 'react';
import { memo, useRef } from 'react';
import { Animated, Easing } from 'react-native';
import { Image } from 'expo-image';
import { Box, Text } from '@/theme/theme';
import { useTheme } from '@shopify/restyle';
Expand All @@ -11,9 +12,11 @@ import { ProgressBar } from '@/components/basic/ProgressBar';
import { getImageSource } from '@/utils/image';
import { type ContinueWatchingEntry } from '@/hooks/useContinueWatching';
import { formatSeasonEpisodeLabel, formatEpisodeCardTitle } from '@/utils/format';
import { CARD_ANIMATION_DURATION, CARD_SCALE_FOCUSED, CARD_SCALE_NORMAL } from '@/constants/media';

const AnimatedBox = Animated.createAnimatedComponent(Box);

interface ContinueWatchingCardProps {
/** The continue watching entry to display */
entry: ContinueWatchingEntry;
hideText?: boolean;
onPress: () => void;
Expand All @@ -34,10 +37,19 @@ export const ContinueWatchingCard = memo(
testID,
}: ContinueWatchingCardProps) => {
const theme = useTheme<Theme>();
const scaleAnim = useRef(new Animated.Value(1)).current;

const animateFocus = (focused: boolean) => {
Animated.timing(scaleAnim, {
toValue: focused ? CARD_SCALE_FOCUSED : CARD_SCALE_NORMAL,
duration: CARD_ANIMATION_DURATION,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}).start();
};

const { isUpNext, progressRatio, video, metaName, imageUrl, key } = entry;

// Derive display values
const clampedProgress = isUpNext ? 0 : Math.min(1, Math.max(0, progressRatio));
const episodeLabel = formatSeasonEpisodeLabel(video);
const title = metaName ?? '';
Expand All @@ -48,20 +60,29 @@ export const ContinueWatchingCard = memo(
<Focusable
onPress={onPress}
onLongPress={onLongPress}
onFocus={() => onFocused?.()}
hasTVPreferredFocus={hasTVPreferredFocus}
withOutline
testID={testID}>
{({ focusStyle }) => (
<Box width={theme.cardSizes.continueWatching.width} gap="s">
testID={testID}
onFocusChange={(isFocused) => {
animateFocus(isFocused);
if (isFocused) onFocused?.();
}}>
{() => (
<AnimatedBox
width={theme.cardSizes.continueWatching.width}
gap="s"
style={{ transform: [{ scale: scaleAnim }] }}
shadowColor="mainForeground"
shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.3}
shadowRadius={6}
elevation={4}>
<Box
height={theme.cardSizes.continueWatching.height}
width={theme.cardSizes.continueWatching.width}
borderRadius="l"
overflow="hidden"
backgroundColor="cardBackground"
position="relative"
style={focusStyle}>
position="relative">
<Image
source={finalImageSource}
style={{ width: '100%', height: '100%' }}
Expand All @@ -79,30 +100,30 @@ export const ContinueWatchingCard = memo(
{episodeLabel && <Badge label={episodeLabel} />}
</Box>

{!isUpNext && clampedProgress > 0 && clampedProgress < 1 ? (
{!isUpNext && clampedProgress > 0 && clampedProgress < 1 && (
<Box position="absolute" left={0} right={0} bottom={0}>
<ProgressBar
testID="continue-watching-progress"
progress={clampedProgress}
height={theme.sizes.progressBarHeight}
/>
</Box>
) : null}
)}
</Box>

{!hideText && (
<Box gap="xs">
<Text variant="cardTitle" numberOfLines={1}>
{title}
</Text>
{subtitle ? (
{subtitle && (
<Text variant="caption" numberOfLines={1} color="textSecondary">
{subtitle}
</Text>
) : null}
)}
</Box>
)}
</Box>
</AnimatedBox>
)}
</Focusable>
);
Expand Down
41 changes: 31 additions & 10 deletions src/components/media/MediaCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { memo } from 'react';
import { memo, useRef } from 'react';
import { Animated, Easing } from 'react-native';
import { Box, Text } from '@/theme/theme';
import { MetaPreview } from '@/types/stremio';
import { Image } from 'expo-image';
Expand All @@ -9,6 +10,9 @@ import { Badge } from '@/components/basic/Badge';
import { NO_POSTER_PORTRAIT } from '@/constants/images';
import { Focusable } from '@/components/basic/Focusable';
import { getImageSource } from '@/utils/image';
import { CARD_ANIMATION_DURATION, CARD_SCALE_FOCUSED, CARD_SCALE_NORMAL } from '@/constants/media';

const AnimatedBox = Animated.createAnimatedComponent(Box);

interface MediaCardProps {
media: MetaPreview;
Expand All @@ -29,45 +33,62 @@ export const MediaCard = memo(
onFocused,
}: MediaCardProps) => {
const theme = useTheme<Theme>();

const scaleAnim = useRef(new Animated.Value(1)).current;
const posterSource = getImageSource(media.poster || media.background, NO_POSTER_PORTRAIT);

const animateFocus = (focused: boolean) => {
Animated.timing(scaleAnim, {
toValue: focused ? CARD_SCALE_FOCUSED : CARD_SCALE_NORMAL,
duration: CARD_ANIMATION_DURATION,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}).start();
};

return (
<Focusable
onPress={() => onPress(media)}
withOutline
testID={testID}
hasTVPreferredFocus={hasTVPreferredFocus}
recyclingKey={media.id}
onFocusChange={(isFocused) => {
animateFocus(isFocused);
if (isFocused) onFocused?.();
}}>
{({ focusStyle }) => (
<Box width={theme.cardSizes.media.width} gap="s">
{() => (
<AnimatedBox
width={theme.cardSizes.media.width}
gap="s"
style={{ transform: [{ scale: scaleAnim }] }}
shadowColor="mainForeground"
shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.3}
shadowRadius={6}
elevation={4}>
<Box
height={theme.cardSizes.media.height}
width={theme.cardSizes.media.width}
borderRadius="l"
overflow="hidden"
backgroundColor="cardBackground"
position="relative"
style={focusStyle}>
position="relative">
<Image
source={posterSource}
style={{ width: '100%', height: '100%' }}
contentFit="cover"
recyclingKey={media.id}
/>

{badgeLabel ? (
{badgeLabel && (
<Box position="absolute" top={theme.spacing.s} right={theme.spacing.s}>
<Badge label={badgeLabel} />
</Box>
) : null}
)}
</Box>
<Text variant="cardTitle" numberOfLines={1}>
{media.name}
</Text>
</Box>
</AnimatedBox>
)}
</Focusable>
);
Expand Down
4 changes: 4 additions & 0 deletions src/constants/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ export const MEDIA_DETAILS_HEADER_COVER_HEIGHT = 350;

// Continue Watching (Home)
export const CONTINUE_WATCHING_PAGE_SIZE = 20;

export const CARD_SCALE_FOCUSED = 1.05;
export const CARD_SCALE_NORMAL = 1;
export const CARD_ANIMATION_DURATION = 200; // ms
Loading