Skip to content

Commit

Permalink
Merge pull request #112 from mykhailodanilenko/feature/homepage-refactor
Browse files Browse the repository at this point in the history
Show more videos on homepage, lazy-load homepage video thumbnails
  • Loading branch information
mykhailodanilenko authored Jul 25, 2024
2 parents a92af9e + 6269a85 commit 38608c1
Show file tree
Hide file tree
Showing 9 changed files with 76 additions and 47 deletions.
2 changes: 1 addition & 1 deletion OwnTube.tv/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const useGetVideosQuery = <TResult = GetVideosVideo[]>({
return getLocalData<{ data: GetVideosVideo[] }>("videos").data;
}

const data = await ApiServiceImpl.getVideos(backend!);
const data = await ApiServiceImpl.getVideos(backend!, 5000);
return data.map((video) => ({ ...video, thumbnailPath: `https://${backend}${video.thumbnailPath}` }));
},
enabled: enabled && !!backend,
Expand Down
2 changes: 1 addition & 1 deletion OwnTube.tv/api/tests/queries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe("useGetVideosQuery", () => {
(useLocalSearchParams as jest.Mock).mockReturnValue({ backend: "abc.xyz" });
const { result } = renderHook(() => useGetVideosQuery({ enabled: true }), { wrapper });
await waitFor(() => expect(getLocalData).not.toHaveBeenCalled());
await waitFor(() => expect(ApiServiceImpl.getVideos).toHaveBeenCalledWith("abc.xyz"));
await waitFor(() => expect(ApiServiceImpl.getVideos).toHaveBeenCalledWith("abc.xyz", 5000));
await waitFor(() =>
expect(result.current.data).toStrictEqual([
{ thumbnailPath: "https://abc.xyz/123f-3fe-3", uuid: "123" },
Expand Down
59 changes: 47 additions & 12 deletions OwnTube.tv/components/CategoryScroll.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,65 @@
import { PropsWithChildren, FC } from "react";
import { View, ScrollView, StyleSheet } from "react-native";
import { useCategoryScroll } from "../hooks";
import { Button } from "./";
import { FC, useCallback, useState } from "react";
import { View, StyleSheet, FlatList, ViewToken } from "react-native";
import { useCategoryScroll, useViewHistory } from "../hooks";
import { Button, VideoThumbnail } from "./";
import { useTheme } from "@react-navigation/native";
import { Ionicons } from "@expo/vector-icons";
import { GetVideosVideo } from "../api/models";
import { useLocalSearchParams } from "expo-router";
import { RootStackParams } from "../app/_layout";
import { ROUTES } from "../types";

export const CategoryScroll: FC<PropsWithChildren> = ({ children }) => {
const { ref, scrollLeft, scrollRight, windowWidth } = useCategoryScroll();
export const CategoryScroll: FC<{ videos: GetVideosVideo[] }> = ({ videos }) => {
const { backend } = useLocalSearchParams<RootStackParams[ROUTES.INDEX]>();
const { getViewHistoryEntryByUuid } = useViewHistory();
const { ref, windowWidth, scrollRight, scrollLeft } = useCategoryScroll();
const { colors } = useTheme();
const [viewableItems, setViewableItems] = useState<string[]>([]);

const renderItem = useCallback(
({ item: video }: { item: GetVideosVideo }) => {
const { timestamp } = getViewHistoryEntryByUuid(video.uuid) || {};

return (
<VideoThumbnail
isVisible={viewableItems.includes(video.uuid)}
key={video.uuid}
video={video}
backend={backend}
timestamp={timestamp}
/>
);
},
[backend, getViewHistoryEntryByUuid],
);

const handleViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: ViewToken<GetVideosVideo>[] }) =>
setViewableItems(viewableItems.map(({ key }) => key)),
[],
);

return (
<View style={styles.horizontalScrollContainer}>
<Button onPress={scrollLeft} style={[styles.scrollButton, { backgroundColor: colors.card }]}>
<Ionicons name="chevron-back" size={20} color={colors.text} />
</Button>

<ScrollView
<FlatList
onViewableItemsChanged={handleViewableItemsChanged}
horizontal
showsHorizontalScrollIndicator={false}
ref={ref}
contentContainerStyle={styles.videoThumbnailsContainer}
data={videos}
renderItem={renderItem}
style={[styles.scrollView, { width: windowWidth - 120 }]}
>
{children}
</ScrollView>

keyExtractor={({ uuid }) => uuid}
viewabilityConfig={{
minimumViewTime: 0,
itemVisiblePercentThreshold: 0,
waitForInteraction: false,
}}
/>
<Button onPress={scrollRight} style={[styles.scrollButton, { backgroundColor: colors.card }]}>
<Ionicons name="chevron-forward" size={20} color={colors.text} />
</Button>
Expand Down
4 changes: 2 additions & 2 deletions OwnTube.tv/components/ChannelAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { Image, StyleSheet, View } from "react-native";
import { LogoNoText } from "./Svg";
import { useTheme } from "@react-navigation/native";

export const ChannelAvatar = ({ imageUri }: { imageUri: string }) => {
export const ChannelAvatar = ({ imageUri }: { imageUri?: string }) => {
const { colors } = useTheme();

return (
<View>
<LogoNoText width={72} height={72} fill={colors.text} stroke={colors.text} />
<View style={styles.imageContainer}>
<Image style={styles.image} source={{ uri: imageUri }} />
<Image style={styles.image} source={imageUri ? { uri: imageUri } : undefined} />
</View>
</View>
);
Expand Down
2 changes: 1 addition & 1 deletion OwnTube.tv/components/VideoChannel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const VideoChannel = ({ channel, data }: Props) => {
return (
<View>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<ChannelAvatar imageUri={`https://${backend}${channel.avatar?.path}`} />
<ChannelAvatar imageUri={channel.avatar?.path ? `https://${backend}${channel.avatar?.path}` : undefined} />
<Typography>{channel.displayName}</Typography>
</View>
{Object.entries(data || {}).map(([category, videos]) => (
Expand Down
4 changes: 2 additions & 2 deletions OwnTube.tv/components/VideoList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";

export const VideoList = () => {
const { t } = useTranslation();
const { data, error, isFetching } = useGetVideosQuery<{
const { data, error, isLoading } = useGetVideosQuery<{
raw: GetVideosVideo[];
videosByChannel: VideosByChannel;
}>({
Expand All @@ -27,7 +27,7 @@ export const VideoList = () => {
return <ErrorMessage message={error.message} />;
}

if (isFetching) {
if (isLoading) {
return <Loader />;
}

Expand Down
18 changes: 14 additions & 4 deletions OwnTube.tv/components/VideoThumbnail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,42 @@ import { getThumbnailDimensions } from "../utils";
import { useColorSchemeContext } from "../contexts";
import { Typography } from "./Typography";
import { useTheme } from "@react-navigation/native";
import { FC } from "react";
import { FC, useEffect, useState } from "react";
import { Link } from "expo-router";
import { ROUTES } from "../types";
import { ViewHistoryEntry } from "../hooks";
import { GetVideosVideo } from "../api/models";
import { Loader } from "./Loader";

interface VideoThumbnailProps {
video: GetVideosVideo & Partial<ViewHistoryEntry>;
backend?: string;
timestamp?: number;
isVisible?: boolean;
}

const defaultImagePaths = {
dark: require("./../assets/logoDark-400x400.png"),
light: require("./../assets/Logo400x400.png"),
};

export const VideoThumbnail: FC<VideoThumbnailProps> = ({ video, backend, timestamp }) => {
export const VideoThumbnail: FC<VideoThumbnailProps> = ({ video, backend, timestamp, isVisible = true }) => {
const { scheme } = useColorSchemeContext();
const [shouldFetchThumbnail, setShouldFetchThumbnail] = useState(false);

const imageSource = video.thumbnailPath ? { uri: video.thumbnailPath } : defaultImagePaths[scheme ?? "dark"];
const { width, height } = getThumbnailDimensions();
const { colors } = useTheme();

const percentageWatched = timestamp ? (timestamp / video.duration) * 100 : 0;

useEffect(() => {
if (isVisible) {
setShouldFetchThumbnail(true);
}
}, [isVisible]);

const imageSource = video.thumbnailPath ? { uri: video.thumbnailPath } : defaultImagePaths[scheme ?? "dark"];

if (!backend) {
return null;
}
Expand All @@ -38,7 +48,7 @@ export const VideoThumbnail: FC<VideoThumbnailProps> = ({ video, backend, timest
style={[styles.videoThumbnailContainer, { height, width }, { backgroundColor: colors.card }]}
href={{ pathname: `/${ROUTES.VIDEO}`, params: { id: video.uuid, backend, timestamp } }}
>
<Image source={imageSource} style={styles.videoImage} />
{shouldFetchThumbnail ? <Image source={imageSource} style={styles.videoImage} /> : <Loader />}
<View style={styles.textContainer}>
{!!percentageWatched && percentageWatched > 0 && (
<View style={styles.progressContainer}>
Expand Down
17 changes: 2 additions & 15 deletions OwnTube.tv/components/VideosByCategory.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { View, StyleSheet } from "react-native";
import { CategoryScroll, Typography, VideoThumbnail } from "./";
import { CategoryScroll, Typography } from "./";
import { FC } from "react";
import { useLocalSearchParams } from "expo-router";
import { RootStackParams } from "../app/_layout";
import { ROUTES } from "../types";
import { useViewHistory } from "../hooks";
import { GetVideosVideo } from "../api/models";

interface VideosByCategoryProps {
Expand All @@ -13,19 +9,10 @@ interface VideosByCategoryProps {
}

export const VideosByCategory: FC<VideosByCategoryProps> = ({ title, videos }) => {
const { backend } = useLocalSearchParams<RootStackParams[ROUTES.INDEX]>();
const { getViewHistoryEntryByUuid } = useViewHistory();

return (
<View style={styles.container}>
<Typography style={styles.categoryTitle}>{title}</Typography>
<CategoryScroll>
{videos.map((video) => {
const { timestamp } = getViewHistoryEntryByUuid(video.uuid) || {};

return <VideoThumbnail key={video.uuid} video={video} backend={backend} timestamp={timestamp} />;
})}
</CategoryScroll>
<CategoryScroll videos={videos} />
</View>
);
};
Expand Down
15 changes: 6 additions & 9 deletions OwnTube.tv/hooks/useCategoryScroll.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { Dimensions, ScrollView } from "react-native";

type ScrollRef = ScrollView | null;
import { Dimensions, FlatList } from "react-native";
import { GetVideosVideo } from "../api/models";

export const useCategoryScroll = () => {
const [windowWidth, setWindowWidth] = useState(Dimensions.get("window").width);
const scrollRefs = useRef<ScrollRef[]>([]);
const ref = useRef<FlatList<GetVideosVideo>>(null);

useEffect(() => {
const subscription = Dimensions.addEventListener("change", ({ window }) => {
Expand All @@ -15,10 +14,8 @@ export const useCategoryScroll = () => {
return () => subscription.remove();
}, []);

const scrollLeft = () => scrollRefs.current[0]?.scrollTo({ x: 0, animated: true });
const scrollRight = () => scrollRefs.current[0]?.scrollToEnd({ animated: true });

const ref = (ref: ScrollRef) => (scrollRefs.current[0] = ref);
const scrollLeft = () => ref.current?.scrollToIndex({ index: 0, animated: true });
const scrollRight = () => ref.current?.scrollToEnd({ animated: true });

return { ref, scrollLeft, scrollRight, windowWidth };
return { ref, windowWidth, scrollRight, scrollLeft };
};

0 comments on commit 38608c1

Please sign in to comment.