Home Page using Next.js, Material UI, TanStack Query - FakeTube #4
Jacek Kościesza

Jacek Kościesza @jacekkosciesza

About: Software Engineer at RocketDNA. AWS Community Builder. Creator of FakeTube - YouTube clone build with AWS.

Location:
Katowice, Poland
Joined:
May 8, 2022

Home Page using Next.js, Material UI, TanStack Query - FakeTube #4

Publish Date: Jul 29
3 0

Let's add a video content to our YouTube clone. In the last episode we created a static app shell with a basic branding and navigation. It's time to create a more dynamic part - home page with a responsive grid of media items.

We will start with a loading skeleton during data fetching phase. When the data are loaded we will display a beautiful thumbnail with all the needed video and channel metadata.

We will also implement some more advanced things like infinite scroll using TanStack Query and React Intersection Observer, as well as an inline media media player.

Last but not least, we will introduce a bonus section, where we will be doing some improvements, small experiments and bug fixes. This time we will fix a small bug related to the masthead position.

Media Skeleton

Skeleton is a placeholder preview of your content, displayed before the data gets loaded.

In our case it shows the basic layout of the media item with a thumbnail, channel avatar and metadata sections.

Media skeleton

web/app/Home/MediaSkeleton/MediaSkeleton.tsx

import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Skeleton from "@mui/material/Skeleton";
import Typography from "@mui/material/Typography";

interface Props {
  animation?: "wave" | "pulse" | false;
}

export function MediaSkeleton({ animation = false }: Props) {
  return (
    <Stack spacing={1.5}>
      <Skeleton
        variant="rectangular"
        animation={animation}
        sx={{
          borderRadius: 3,
          height: "inherit",
          aspectRatio: "16 / 9",
        }}
      />
      <Stack direction="row" spacing={1}>
        <Box>
          <Skeleton
            variant="circular"
            width={36}
            height={36}
            animation={animation}
          />
        </Box>
        <Stack width="100%">
          <Typography variant="subtitle1">
            <Skeleton variant="text" width="100%" animation={animation} />
          </Typography>
          <Typography variant="subtitle2">
            <Skeleton variant="text" width="50%" animation={animation} />
          </Typography>
        </Stack>
      </Stack>
    </Stack>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/Home/MediaSkeleton/index.ts

export * from "./MediaSkeleton";
Enter fullscreen mode Exit fullscreen mode

Home Page

Let's create a dedicated HomePage component and use it on our default page.

We will use our MediaSkeleton to show a data loading state of our home page. Let's create an array of media skeletons and display them. You will notice that we delegated the task of creating grid layout to another component called BrowseGrid.

web/app/Home/HomePage.tsx

import { BrowseGrid } from "./BrowseGrid";
import { MediaSkeleton } from "./MediaSkeleton";

const PAGE_SIZE = 24;

export function HomePage() {
  const skeleton = (
    <>
      {Array.from({ length: PAGE_SIZE }).map((_, index) => (
        <MediaSkeleton key={index} />
      ))}
    </>
  );

  return <BrowseGrid>{skeleton}</BrowseGrid>;
}
Enter fullscreen mode Exit fullscreen mode

web/app/Home/index.tsx

export * from "./HomePage";
Enter fullscreen mode Exit fullscreen mode

web/app/page.tsx (diff)

-import Typography from "@mui/material/Typography";
+import { HomePage } from "./Home";

export default function Home() {
-  return (
-    <Typography component="h2" variant="h4">
-      TODO: Home Page
-    </Typography>
-  );
+  return <HomePage />;
}
Enter fullscreen mode Exit fullscreen mode

Browse Grid

Grid is a responsive layout that adapts to screen size and orientation.

We will use it to display our media skeletons and items and ensure that the number of columns adopts to the screen resolution.

You might have noticed that we are not passing an array of skeletons directly, but rather encapsulated in a single <> (Fragment) element. It makes it harder to iterate over, but increases our component's flexibility.

To make our lives easier, we will use react-keyed-flatten-children library

it will flatten nested arrays and React.Fragments into a regular, one-dimensional array while ensuring element and fragment keys are preserved, unique, and stable between renders.

Let's install that library and its dependencies

npm install react-keyed-flatten-children react-is@19
Enter fullscreen mode Exit fullscreen mode

web/app/Home/BrowseGrid/BrowseGrid.tsx

import * as React from "react";
import Grid from "@mui/material/Grid";
import flattenChildren from "react-keyed-flatten-children";

interface Props {
  children: React.ReactNode;
}

export function BrowseGrid({ children }: Props) {
  return (
    <Grid container rowSpacing={4} columnSpacing={2}>
      {React.Children.map(flattenChildren(children), (child) => (
        <Grid size={{ xs: 12, sm: 6, md: 6, lg: 3, xl: 3, xxl: 3, xxxl: 3 }}>
          {child}
        </Grid>
      ))}
    </Grid>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/Home/BrowseGrid/index.ts

export * from "./BrowseGrid";
Enter fullscreen mode Exit fullscreen mode

The final result looks like this

Home page skeleton

GitHub: feat(home): browse grid, media skeleton (#7)

Data

Before we display the grid of media items, we have to define our data models (video and channels) and create mock data.

Data models

web/app/Home/video.ts

import { Channel } from "./channel";

export interface Video {
  id: string;
  title: string;
  thumbnail: string;
  duration: string;
  url: string;
  publishedAt: string;
  channel: Channel;
}
Enter fullscreen mode Exit fullscreen mode

web/app/Home/channel.ts

export interface Channel {
  id: string;
  avatar: string;
  name: string;
}
Enter fullscreen mode Exit fullscreen mode

Mock data

All the mock data are based on the content generated by AI in the Generative AI Videos using Bedrock, Polly, iMovie - FakeTube #2 episode. Because we uploaded all of the generated videos to the YouTube, we can use metadata like id and publishedAt directly from there. Finally we will copy channel avatar, video thumbnails and files to the Next.js public directory.

web/app/Home/channels.data.ts

import { Channel } from "./channel";

export const CHANNELS: Channel[] = [
  {
    id: "AmazonNovaReel",
    avatar: "/channels/AmazonNovaReel/AmazonNovaReel.png",
    name: "Amazon Nova Reel",
  },
];
Enter fullscreen mode Exit fullscreen mode

web/app/Home/videos.data.ts

import { CHANNELS } from "./channels.data";
import { Video } from "./video";

const channel = CHANNELS[0];

export const VIDEOS: Video[] = [
  {
    id: "q9Gm7a6Wwjk",
    title: "The Amazing World of Octopus!",
    thumbnail: "/videos/q9Gm7a6Wwjk/q9Gm7a6Wwjk.png",
    duration: "PT0M6.214542S",
    url: "/videos/q9Gm7a6Wwjk/q9Gm7a6Wwjk.mp4",
    publishedAt: "2025-03-03T15:58:23Z",
    channel,
  },
  {
    id: "QYUGZ3ueoHQ",
    title: "Magic Wheels: The Future of Cars",
    thumbnail: "/videos/QYUGZ3ueoHQ/QYUGZ3ueoHQ.png",
    duration: "PT0M6.047708S",
    url: "/videos/QYUGZ3ueoHQ/QYUGZ3ueoHQ.mp4",
    publishedAt: "2025-03-03T14:22:54Z",
    channel,
  },
  {
    id: "SkrDa20qGGo",
    title: "Dancing Pets: A Fun Animal Show",
    thumbnail: "/videos/SkrDa20qGGo/SkrDa20qGGo.png",
    duration: "PT0M6.047708S",
    url: "/videos/SkrDa20qGGo/SkrDa20qGGo.mp4",
    publishedAt: "2025-03-04T15:13:16Z",
    channel,
  },
  {
    id: "snI0xHnk9vw",
    title: "Learning with Fun: The Magic of Numbers",
    thumbnail: "/videos/snI0xHnk9vw/snI0xHnk9vw.png",
    duration: "PT0M6.047708S",
    url: "/videos/snI0xHnk9vw/snI0xHnk9vw.mp4",
    publishedAt: "2025-03-04T15:53:08Z",
    channel,
  },
  {
    id: "tO-6SL2drHA",
    title: "Learning with Fun: The Magic of Science",
    thumbnail: "/videos/tO-6SL2drHA/tO-6SL2drHA.png",
    duration: "PT0M6.047708S",
    url: "/videos/tO-6SL2drHA/tO-6SL2drHA.mp4",
    publishedAt: "2025-03-04T16:10:52Z",
    channel,
  },
  {
    id: "51KK6cQwqdo",
    title: "Desert Motorcycle Adventure",
    thumbnail: "/videos/51KK6cQwqdo/51KK6cQwqdo.png",
    duration: "PT0M6.047708S",
    url: "/videos/51KK6cQwqdo/51KK6cQwqdo.mp4",
    publishedAt: "2025-03-04T16:37:57Z",
    channel,
  },
  {
    id: "6Pz1MlphyvA",
    title: "Amazing Soccer Tricks",
    thumbnail: "/videos/6Pz1MlphyvA/6Pz1MlphyvA.png",
    duration: "PT0M6.047708S",
    url: "/videos/6Pz1MlphyvA/6Pz1MlphyvA.mp4",
    publishedAt: "2025-03-04T16:54:19Z",
    channel,
  },
  {
    id: "rX4LkHQLKSw",
    title: "Tennis Magic: The Spin of Champions",
    thumbnail: "/videos/rX4LkHQLKSw/rX4LkHQLKSw.png",
    duration: "PT0M6.047708S",
    url: "/videos/rX4LkHQLKSw/rX4LkHQLKSw.mp4",
    publishedAt: "2025-03-04T17:12:16Z",
    channel,
  },
  {
    id: "mYiQU9_bvAA",
    title: "Mud Monster Truck Adventure",
    thumbnail: "/videos/mYiQU9_bvAA/mYiQU9_bvAA.png",
    duration: "PT0M6.047708S",
    url: "/videos/mYiQU9_bvAA/mYiQU9_bvAA.mp4",
    publishedAt: "2025-03-05T15:47:29Z",
    channel,
  },
  {
    id: "1ccSDKMvpGA",
    title: "Exploring the Magic of Motorhomes",
    thumbnail: "/videos/1ccSDKMvpGA/1ccSDKMvpGA.png",
    duration: "PT0M6.047708S",
    url: "/videos/1ccSDKMvpGA/1ccSDKMvpGA.mp4",
    publishedAt: "2025-03-05T16:01:54Z",
    channel,
  },
  {
    id: "8ibeIVJKsYQ",
    title: "Rocket Science: Blast Off!",
    thumbnail: "/videos/8ibeIVJKsYQ/8ibeIVJKsYQ.png",
    duration: "PT0M6.047708S",
    url: "/videos/8ibeIVJKsYQ/8ibeIVJKsYQ.mp4",
    publishedAt: "2025-03-05T16:20:43Z",
    channel,
  },
  {
    id: "PcxHBLxkNuA",
    title: "The Magic of Magnets",
    thumbnail: "/videos/PcxHBLxkNuA/PcxHBLxkNuA.png",
    duration: "PT0M6.047708S",
    url: "/videos/PcxHBLxkNuA/PcxHBLxkNuA.mp4",
    publishedAt: "2025-03-05T16:30:52Z",
    channel,
  },
  {
    id: "j5az8g8QEZ4",
    title: "Snail's Slow and Steady Adventure",
    thumbnail: "/videos/j5az8g8QEZ4/j5az8g8QEZ4.png",
    duration: "PT0M6.047708S",
    url: "/videos/j5az8g8QEZ4/j5az8g8QEZ4.mp4",
    publishedAt: "2025-03-05T16:44:19Z",
    channel,
  },
  {
    id: "j4aY8zGqcLQ",
    title: "Rattlesnake's Dance: A Wild Adventure",
    thumbnail: "/videos/j4aY8zGqcLQ/j4aY8zGqcLQ.png",
    duration: "PT0M6.047708S",
    url: "/videos/j4aY8zGqcLQ/j4aY8zGqcLQ.mp4",
    publishedAt: "2025-03-05T16:56:10Z",
    channel,
  },
  {
    id: "ZG5ixKK6ABs",
    title: "Classic Golf: The Art of the Swing",
    thumbnail: "/videos/ZG5ixKK6ABs/ZG5ixKK6ABs.png",
    duration: "PT0M6.047708S",
    url: "/videos/ZG5ixKK6ABs/ZG5ixKK6ABs.mp4",
    publishedAt: "2025-03-05T17:18:03Z",
    channel,
  },
  {
    id: "cJanxpcCwq0",
    title: "Retro Hockey: A Blast from the Past",
    thumbnail: "/videos/cJanxpcCwq0/cJanxpcCwq0.png",
    duration: "PT0M6.047708S",
    url: "/videos/cJanxpcCwq0/cJanxpcCwq0.mp4",
    publishedAt: "2025-03-05T17:34:12Z",
    channel,
  },
  {
    id: "7qgegT-6tq8",
    title: "Exploring Magical Festivals",
    thumbnail: "/videos/7qgegT-6tq8/7qgegT-6tq8.png",
    duration: "PT0M6.047708S",
    url: "/videos/7qgegT-6tq8/7qgegT-6tq8.mp4",
    publishedAt: "2025-03-06T15:15:29Z",
    channel,
  },
  {
    id: "wCYxsFwXVAk",
    title: "Adventure Awaits: Mountain Hiking",
    thumbnail: "/videos/wCYxsFwXVAk/wCYxsFwXVAk.png",
    duration: "PT0M6.047708S",
    url: "/videos/wCYxsFwXVAk/wCYxsFwXVAk.mp4",
    publishedAt: "2025-03-06T15:30:21Z",
    channel,
  },
  {
    id: "aD5_u3q6KUM",
    title: "City Adventure: Walking Tour",
    thumbnail: "/videos/aD5_u3q6KUM/aD5_u3q6KUM.png",
    duration: "PT0M6.047708S",
    url: "/videos/aD5_u3q6KUM/aD5_u3q6KUM.mp4",
    publishedAt: "2025-03-06T15:52:52Z",
    channel,
  },
  {
    id: "aFwvWFVIUww",
    title: "Live from the Big Conference!",
    thumbnail: "/videos/aFwvWFVIUww/aFwvWFVIUww.png",
    duration: "PT0M6.047708S",
    url: "/videos/aFwvWFVIUww/aFwvWFVIUww.mp4",
    publishedAt: "2025-03-06T16:06:33Z",
    channel,
  },
  {
    id: "zGl4juMoUow",
    title: "RTS Battle: Strategy in Action",
    thumbnail: "/videos/zGl4juMoUow/zGl4juMoUow.png",
    duration: "PT0M6.047708S",
    url: "/videos/zGl4juMoUow/zGl4juMoUow.mp4",
    publishedAt: "2025-03-06T16:20:51Z",
    channel,
  },
  {
    id: "cCps60RZP4g",
    title: "Retro Platformer Adventure",
    thumbnail: "/videos/cCps60RZP4g/cCps60RZP4g.png",
    duration: "PT0M6.047708S",
    url: "/videos/cCps60RZP4g/cCps60RZP4g.mp4",
    publishedAt: "2025-03-06T16:34:34Z",
    channel,
  },
  {
    id: "LvQMMXWXOvE",
    title: "Chess Showdown: The Ultimate Battle",
    thumbnail: "/videos/LvQMMXWXOvE/LvQMMXWXOvE.png",
    duration: "PT0M6.047708S",
    url: "/videos/LvQMMXWXOvE/LvQMMXWXOvE.mp4",
    publishedAt: "2025-03-06T16:54:22Z",
    channel,
  },
  {
    id: "z-dBt8MpAnA",
    title: "Chess Showdown: The Ultimate Battle",
    thumbnail: "/videos/z-dBt8MpAnA/z-dBt8MpAnA.png",
    duration: "PT0M6.047708S",
    url: "/videos/z-dBt8MpAnA/z-dBt8MpAnA.mp4",
    publishedAt: "2025-03-06T17:16:39Z",
    channel,
  },
  {
    id: "k1DF_Rcan6M",
    title: "Standing Up for Change: Peaceful Road Block",
    thumbnail: "/videos/k1DF_Rcan6M/k1DF_Rcan6M.png",
    duration: "PT0M6.047708S",
    url: "/videos/k1DF_Rcan6M/k1DF_Rcan6M.mp4",
    publishedAt: "2025-03-07T14:05:46Z",
    channel,
  },
  {
    id: "SJOCLMEuoh0",
    title: "Tree Guardians: Protecting Nature's Champions",
    thumbnail: "/videos/SJOCLMEuoh0/SJOCLMEuoh0.png",
    duration: "PT0M6.047708S",
    url: "/videos/SJOCLMEuoh0/SJOCLMEuoh0.mp4",
    publishedAt: "2025-03-07T14:20:26Z",
    channel,
  },
  {
    id: "M8V1FcKde2g",
    title: "Street Volunteers: Collecting for a Cause",
    thumbnail: "/videos/M8V1FcKde2g/M8V1FcKde2g.png",
    duration: "PT0M6.047708S",
    url: "/videos/M8V1FcKde2g/M8V1FcKde2g.mp4",
    publishedAt: "2025-03-07T14:36:03Z",
    channel,
  },
  {
    id: "saYIayqn6I0",
    title: "Charity Run: Running Together for a Cause",
    thumbnail: "/videos/saYIayqn6I0/saYIayqn6I0.png",
    duration: "PT0M6.047708S",
    url: "/videos/saYIayqn6I0/saYIayqn6I0.mp4",
    publishedAt: "2025-03-07T14:48:26Z",
    channel,
  },
  {
    id: "NzB9W_14tgE",
    title: "Hair Magic: A Girl's New Haircut",
    thumbnail: "/videos/NzB9W_14tgE/NzB9W_14tgE.png",
    duration: "PT0M6.047708S",
    url: "/videos/NzB9W_14tgE/NzB9W_14tgE.mp4",
    publishedAt: "2025-03-07T15:01:18Z",
    channel,
  },
  {
    id: "RQmLNnELeFQ",
    title: "Fashion Forward: Catwalk Chic",
    thumbnail: "/videos/RQmLNnELeFQ/RQmLNnELeFQ.png",
    duration: "PT0M6.047708S",
    url: "/videos/RQmLNnELeFQ/RQmLNnELeFQ.mp4",
    publishedAt: "2025-03-07T15:13:21Z",
    channel,
  },
  {
    id: "8tS7B-c0b_8",
    title: "Easy Soup Cooking Fun",
    thumbnail: "/videos/8tS7B-c0b_8/8tS7B-c0b_8.png",
    duration: "PT0M6.047708S",
    url: "/videos/8tS7B-c0b_8/8tS7B-c0b_8.mp4",
    publishedAt: "2025-03-07T15:42:59Z",
    channel,
  },
  {
    id: "EoptO2hf3tY",
    title: "How to Use a Yo Yo: Fun Tricks for Beginners",
    thumbnail: "/videos/EoptO2hf3tY/EoptO2hf3tY.png",
    duration: "PT0M6.047708S",
    url: "/videos/EoptO2hf3tY/EoptO2hf3tY.mp4",
    publishedAt: "2025-03-07T16:08:48Z",
    channel,
  },
];
Enter fullscreen mode Exit fullscreen mode
web
├── public
│   ├── channels
│   │   ├── AmazonNovaReel
│   │   │   ├── AmazonNovaReel.png
│   ├── vidoes
│   │   ├── q9Gm7a6Wwjk
│   │   │   ├── q9Gm7a6Wwjk.png
│   │   │   ├── q9Gm7a6Wwjk.mp4
│   │   ├── QYUGZ3ueoHQ
│   │   │   ├── QYUGZ3ueoHQ.png
│   │   │   ├── QYUGZ3ueoHQ.mp4
|   ├── ...
| ...
Enter fullscreen mode Exit fullscreen mode

GitHub: feat(home): mock data (#7)

Media Item

Let's shift our focus to the most important component, which will represent video on the home page, that is Media Item. It will consist of the two main parts: Video Thumbnail and Video Details.

Media item

web/app/Home/MediaItem/MediaItem.tsx

import Box from "@mui/material/Box";

import { Video } from "../video";
import { VideoDetails } from "./VideoDetails";
import { VideoThumbnail } from "./VideoThumbnail";

interface Props {
  video: Video;
}

export function MediaItem({ video }: Props) {
  return (
    <Box
      sx={{
        width: "100%",
        mb: 2,
        display: "block",
        aspectRatio: "16 / 9",
      }}
    >
      <VideoThumbnail video={video} />
      <VideoDetails video={video} />
    </Box>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/Home/MediaItem/index.tsx

export * from "./MediaItem";
Enter fullscreen mode Exit fullscreen mode

Video Thumbnail

This component will obviously show an image representation of our video, which is called a thumbnail.

Additionally it should display the duration of the video in the bottom-right corner. We will extract this functionality to a Time Status component.

web/app/Home/MediaItem/VideoThumbnail/VideoThumbnail.tsx

import Box from "@mui/material/Box";

import { Video } from "../../video";
import { TimeStatus } from "../TimeStatus";

interface Props {
  video: Video;
}

export function VideoThumbnail({ video }: Props) {
  return (
    <Box
      sx={{
        width: "100%",
        height: "100%",
        position: "relative",
        mb: 1.5,
      }}
    >
      <Box
        component="img"
        alt=""
        src={video.thumbnail}
        sx={{
          borderRadius: 3,
          aspectRatio: "16 / 9",
          maxWidth: "100%",
          display: "block",
          objectFit: "cover",
        }}
      />
      <TimeStatus time={video.duration} />
    </Box>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/Home/MediaItem/VideoThumbnail/index.tsx

export * from "./VideoThumbnail";
Enter fullscreen mode Exit fullscreen mode

Time Status

It's a slightly customized Chip component. We will have to convert ISO_8601 duration format into human readable format like 0:07. We can easily do that with the Luxon library.

Let's first install needed dependencies

npm install luxon
npm i --save-dev @types/luxon
Enter fullscreen mode Exit fullscreen mode

web/app/Home/MediaItem/TimeStatus/TimeStatus.tsx

"use client";

import * as React from "react";
import Chip from "@mui/material/Chip";
import { Duration } from "luxon";

interface Props {
  time: string;
}

export function TimeStatus({ time }: Props) {
  const duration: string = React.useMemo(() => {
    const seconds = Duration.fromISO(time).as("seconds");
    return Duration.fromObject({ seconds: Math.ceil(seconds) }).toFormat(
      "m:ss"
    );
  }, [time]);

  return (
    <Chip
      size="small"
      label={duration}
      sx={{
        position: "absolute",
        bottom: (theme) => theme.spacing(1.5),
        right: (theme) => theme.spacing(1),
        borderRadius: 1,
        color: "white",
        fontWeight: 600,
        backgroundColor: "#00000099",
        ".MuiChip-label": {
          padding: 0.5,
        },
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/Home/MediaItem/TimeStatus/index.tsx

export * from "./TimeStatus";
Enter fullscreen mode Exit fullscreen mode

Video Details

The second part of the MediaItem component is responsible for displaying video and channel metadata like channel avatar and name, video title and publish date. There is some work related to its layout, plus we will have to convert the video publish date to a more convenient relative format like 1 month ago. The good news is that we can also do that using Luxon.

web/app/Home/MediaItem/VideoDetails/VideoDetails.tsx

"use client";

import * as React from "react";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { DateTime } from "luxon";

import { Video } from "../../video";

interface Props {
  video: Video;
}

export function VideoDetails({ video }: Props) {
  const date: string | null = React.useMemo(
    () =>
      DateTime.fromISO(video.publishedAt).toRelative({
        unit: ["years", "months", "weeks", "days"],
      }),
    [video.publishedAt]
  );

  return (
    <Box sx={{ display: "flex", alignItems: "flex-start" }}>
      <Avatar
        src={video.channel.avatar}
        alt={video.channel.name}
        sx={{
          width: (theme) => theme.spacing(4.5),
          height: (theme) => theme.spacing(4.5),
          marginRight: (theme) => theme.spacing(1),
        }}
      />
      <Stack>
        <Typography
          variant="subtitle2"
          component="h3"
          fontSize={16}
          gutterBottom
          mb={0.5}
        >
          {video.title}
        </Typography>
        <Stack>
          <Tooltip
            title={video.channel.name}
            placement="top"
            disableInteractive
          >
            <Typography
              variant="caption"
              fontSize={14}
              sx={{
                color: "#606060",
                "&:hover": {
                  color: (theme) => theme.palette.text.primary,
                },
              }}
            >
              {video.channel.name}
            </Typography>
          </Tooltip>
        </Stack>

        <Typography
          variant="caption"
          fontSize={14}
          sx={{
            color: "#606060",
          }}
        >
          {date}
        </Typography>
      </Stack>
    </Box>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/Home/MediaItem/VideoDetails/index.tsx

export * from "./VideoDetails";
Enter fullscreen mode Exit fullscreen mode

Home Page

The media item component is ready, so it's time to use it on our home page. When loading is done (for now we will just mock it using loading variable which is set to false), we will pass array of MediaItems based on our mock data to the BrowseGrid;

web/app/Home/HomePage.tsx (diff)

import { BrowseGrid } from "./BrowseGrid";
+import { MediaItem } from "./MediaItem";
import { MediaSkeleton } from "./MediaSkeleton";
+import { VIDEOS } from "./videos.data";

const PAGE_SIZE = 24;

export function HomePage() {
+ const loading = false;

  const skeleton = (
    <>
      {Array.from({ length: PAGE_SIZE }).map((_, index) => (
        <MediaSkeleton key={index} />
      ))}
    </>
  );

- return <BrowseGrid>{skeleton}</BrowseGrid>;
+  return (
+    <BrowseGrid>
+      {loading ? (
+        skeleton
+      ) : (
+        <>
+         {VIDEOS.map((video) => (
+           <MediaItem video={video} key={video.id} />
+         ))}
+       </>
+      )}
+    </BrowseGrid>
+  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/Home/HomePage.tsx

import { BrowseGrid } from "./BrowseGrid";
import { MediaItem } from "./MediaItem";
import { MediaSkeleton } from "./MediaSkeleton";
import { VIDEOS } from "./videos.data";

const PAGE_SIZE = 24;

export function HomePage() {
  const loading = false;

  const skeleton = (
    <>
      {Array.from({ length: PAGE_SIZE }).map((_, index) => (
        <MediaSkeleton key={index} />
      ))}
    </>
  );

  return (
    <BrowseGrid>
      {loading ? (
        skeleton
      ) : (
        <>
          {VIDEOS.map((video) => (
            <MediaItem video={video} key={video.id} />
          ))}
        </>
      )}
    </BrowseGrid>
  );
}
Enter fullscreen mode Exit fullscreen mode

Home page media items

GitHub: feat(home): media item (#8)

Fetching data

So far we just directly rendered our mock data on the home page. In the next episodes we will store data in the database and build an API to fetch them. What we can do right now is to prepare frontend for scenario like that, but still return mock data from web/app/Home/videos.data.ts. We will use TansStack Query for that. It's asynchronous state management library, which provides very handy abstraction layer for operations like that, including infinite queries.

Setup

TansStack Query provides quite good installation and quick start documentation.

Let's install the needed dependency and configure the query client provider.

npm install @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode

web/app/providers.tsx (diff)

"use client";

import CssBaseline from "@mui/material/CssBaseline";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import { ThemeProvider } from "@mui/material/styles";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import { theme } from "./theme";

+const queryClient = new QueryClient();

export function Providers({
  children,
  deviceType,
}: Readonly<{
  children: React.ReactNode;
  deviceType: string;
}>) {
  return (
    <AppRouterCacheProvider>
+     <QueryClientProvider client={queryClient}>
        <ThemeProvider theme={theme(deviceType)}>
          <CssBaseline />
          {children}
        </ThemeProvider>
+     </QueryClientProvider>
    </AppRouterCacheProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/providers.tsx

"use client";

import CssBaseline from "@mui/material/CssBaseline";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import { ThemeProvider } from "@mui/material/styles";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import theme from "./theme";

const queryClient = new QueryClient();

export function Providers({
  children,
  deviceType,
}: Readonly<{
  children: React.ReactNode;
  deviceType: string;
}>) {
  return (
    <AppRouterCacheProvider>
      <QueryClientProvider client={queryClient}>
        <ThemeProvider theme={theme(deviceType)}>
          <CssBaseline />
          {children}
        </ThemeProvider>
      </QueryClientProvider>
    </AppRouterCacheProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Infinite scroll

Next step is to define our own useListVideos hook, which will be responsible for fetching page of videos, with some simulated delay.

It will use useInfiniteQuery under the hood, which was designed for UI patterns which automatically load more data.

Rendering lists that can additively "load more" data onto an existing set of data or "infinite scroll" is also a very common UI pattern. TanStack Query supports a useful version of useQuery called useInfiniteQuery for querying these types of lists.

We will implement pagination and introduce 1 second delay to simulate fetching data from the backend and show skeleton, which we already implemented.

web/app/Home/useListVideos.tsx

import { useInfiniteQuery } from "@tanstack/react-query";

import { Page } from "./page";
import { Video } from "./video";
import { VIDEOS } from "./videos.data";

export const PAGE_SIZE = 24;
const DELAY_MS = 1000;

const fetch = async (
  currentPage: number,
  pageSize: number = PAGE_SIZE
): Promise<Page<Video>> => {
  await new Promise((resolve) => setTimeout(resolve, DELAY_MS));

  const start = currentPage * pageSize;
  const end = start + pageSize;

  return {
    items: VIDEOS.slice(start, end),
    currentPage,
    hasNextPage: end < VIDEOS.length,
  };
};

export const useListVideos = () => {
  return useInfiniteQuery({
    queryKey: ["listVideos"],
    queryFn: ({ pageParam: page }) => fetch(page),
    initialPageParam: 0,
    getNextPageParam: (lastPage) =>
      lastPage.hasNextPage ? lastPage.currentPage + 1 : undefined,
  });
};
Enter fullscreen mode Exit fullscreen mode

web/app/Home/page.ts

export interface Page<T> {
  items: T[];
  currentPage: number;
  hasNextPage: boolean;
}
Enter fullscreen mode Exit fullscreen mode

Home Page

It's time to use our useListVideos hook on the home page. Thanks to TanStack Query it not only returns paginated data, but also some useful statuses of the operation (e.g. pending) or helper functions (e.g. fetchNextPage).

We will the show skeleton during the initial page load, but also when we will be fetching the next page. In that case we will also show a small circular progress, like they do on YouTube.

We want to trigger fetching the next page when we scroll down and reach end of our list of media items. Let's use React Intersection Observer for a trigger like that. We will add a Box just after BrowseGrid. When that box is visible (in the view), it will change the state of the inView variable to true, which will fetch the next page as a consequence.

npm install react-intersection-observer
Enter fullscreen mode Exit fullscreen mode

web/app/Home/HomePage.tsx (diff)

+"use client";
+
+import React from "react";
+import Box from "@mui/material/Box";
+import CircularProgress from "@mui/material/CircularProgress";
+import { useInView } from "react-intersection-observer";
+
import { BrowseGrid } from "./BrowseGrid";
import { MediaItem } from "./MediaItem";
import { MediaSkeleton } from "./MediaSkeleton";
-import { VIDEOS } from "./videos.data";
+import { PAGE_SIZE, useListVideos } from "./useListVideos";
-
-const PAGE_SIZE = 24;

export function HomePage() {
- const loading = false;
+ const { ref, inView } = useInView();

+ const {
+   status,
+   data,
+   isFetching,
+   isFetchingNextPage,
+   fetchNextPage,
+   hasNextPage,
+ } = useListVideos();
+
+ const videos = data?.pages.flatMap((page) => page.items);
+
+ React.useEffect(() => {
+   if (inView) {
+     if (!isFetching && !isFetchingNextPage && hasNextPage) {
+       fetchNextPage();
+     }
+   }
+ }, [inView, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage]);

  const skeleton = (
    <>
      {Array.from({ length: PAGE_SIZE }).map((_, index) => (
        <MediaSkeleton key={index} />
      ))}
    </>
  );

  return (
-   <BrowseGrid>
-     {loading ? (
-       skeleton
-     ) : (
-       <>
-         {VIDEOS.map((video) => (
-           <MediaItem video={video} key={video.id} />
-         ))}
-       </>
-     )}
-   </BrowseGrid>
+   <>
+     <BrowseGrid>
+       {status === "pending" ? (
+         skeleton
+       ) : (
+         <>
+           {(videos || []).map((video) => (
+             <MediaItem key={video.id} video={video} />
+           ))}
+           {isFetchingNextPage && skeleton}
+         </>
+       )}
+     </BrowseGrid>
+     <Box ref={ref} sx={{ textAlign: "center", height: 2, my: 2 }}>
+       {isFetchingNextPage && <CircularProgress />}
+     </Box>
+   </>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/Home/HomePage.tsx

"use client";

import React from "react";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import { useInView } from "react-intersection-observer";

import { BrowseGrid } from "./BrowseGrid";
import { MediaItem } from "./MediaItem";
import { MediaSkeleton } from "./MediaSkeleton";
import { PAGE_SIZE, useListVideos } from "./useListVideos";

export function HomePage() {
  const { ref, inView } = useInView();

  const {
    status,
    data,
    isFetching,
    isFetchingNextPage,
    fetchNextPage,
    hasNextPage,
  } = useListVideos();

  const videos = data?.pages.flatMap((page) => page.items);

  React.useEffect(() => {
    if (inView) {
      if (!isFetching && !isFetchingNextPage && hasNextPage) {
        fetchNextPage();
      }
    }
  }, [inView, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage]);

  const skeleton = (
    <>
      {Array.from({ length: PAGE_SIZE }).map((_, index) => (
        <MediaSkeleton key={index} />
      ))}
    </>
  );

  return (
    <>
      <BrowseGrid>
        {status === "pending" ? (
          skeleton
        ) : (
          <>
            {(videos || []).map((video) => (
              <MediaItem key={video.id} video={video} />
            ))}
            {isFetchingNextPage && skeleton}
          </>
        )}
      </BrowseGrid>
      <Box ref={ref} sx={{ textAlign: "center", height: 2, my: 2 }}>
        {isFetchingNextPage && <CircularProgress />}
      </Box>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Fetching data

GitHub: feat(home): fetching data (#8)

Video player

There are two ways to play a YouTube video. The first one is just to click a media item. It will take you to a watch page, with a video player which will automatically start playback. We don't have this page yet, but we will build it in the coming episodes.

The second one is to hover over a media item. It will replace a video thumbnail with an inline video player and automatically start it. This is what we are going to implement right now.

This inline video player will not have any controls, except for a mute/unmute button. State of audio will be lifted up to the media item in order to preserve it every time you activate an inline video player.

We will also implement a playback progress indicator using a determinate variant of a linear progress component.

TimeStatus in the bottom-right corner will be more dynamic. It will be updated in the real-time and show a remaining video duration

web/app/Home/MediaItem/VideoPlayer/VideoPlayer.tsx

"use client";

import * as React from "react";
import Box from "@mui/material/Box";
import LinearProgress from "@mui/material/LinearProgress";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import VolumeOffIcon from "@mui/icons-material/VolumeOff";
import VolumeUpOutlinedIcon from "@mui/icons-material/VolumeUpOutlined";
import { Duration } from "luxon";
import { styled } from "@mui/material/styles";

import { Video } from "../../video";
import { TimeStatus } from "../TimeStatus";

interface Props {
  video: Pick<Video, "duration" | "url">;
  isMuted: boolean;
  toggleMute: () => void;
}

const StyledVideo = styled("video")({
  aspectRatio: "16 / 9",
  maxWidth: "100%",
  display: "block",
  objectFit: "cover",
});

export function VideoPlayer({ video, isMuted, toggleMute }: Props) {
  const [progress, setProgress] = React.useState(0);
  const [remainingDuration, setRemainingDuration] = React.useState(
    video.duration
  );

  const handleTimeUpdate = ({
    currentTarget: { currentTime, duration },
  }: React.SyntheticEvent<HTMLVideoElement>) => {
    updateProgressIndicator(currentTime, duration);
    updateRemainingDuration(currentTime);
  };

  const updateProgressIndicator = (currentTime: number, duration: number) => {
    const progressPercentage = (currentTime / duration) * 100;
    setProgress(progressPercentage);
  };

  const updateRemainingDuration = (currentTime: number) => {
    const totalDurationInSeconds = Duration.fromISO(video.duration).as(
      "seconds"
    );
    const remainingTimeInSeconds = totalDurationInSeconds - currentTime;

    setRemainingDuration(
      Duration.fromObject({ seconds: remainingTimeInSeconds }).toISO()
    );
  };

  return (
    <Box
      sx={{
        width: "100%",
        height: "100%",
        position: "relative",
        mb: 1.5,
      }}
    >
      <StyledVideo
        src={video.url}
        autoPlay
        loop
        muted={isMuted}
        preload="metadata"
        controlsList="nodownload"
        disablePictureInPicture
        disableRemotePlayback
        onTimeUpdate={handleTimeUpdate}
      />
      <Tooltip
        title={isMuted ? "Unmute" : "Mute"}
        enterDelay={500}
        disableInteractive
      >
        <IconButton
          onClick={toggleMute}
          sx={{
            position: "absolute",
            top: (theme) => theme.spacing(1),
            right: (theme) => theme.spacing(1),
            backgroundColor: "#00000099",
            color: "white",
            "&:hover": {
              backgroundColor: "#00000099",
            },
          }}
        >
          {isMuted ? <VolumeOffIcon /> : <VolumeUpOutlinedIcon />}
        </IconButton>
      </Tooltip>
      <Box
        sx={{
          position: "absolute",
          height: 3,
          left: 0,
          right: 0,
          bottom: 1,
        }}
      >
        <LinearProgress variant="determinate" value={progress} color="error" />
      </Box>
      <TimeStatus time={remainingDuration} />
    </Box>
  );
}
Enter fullscreen mode Exit fullscreen mode

web/app/Home/MediaItem/VideoPlayer/index.ts

export * from "./VideoPlayer";
Enter fullscreen mode Exit fullscreen mode

web/app/Home/MediaItem/MediaItem.tsx (diff)

+"use client";
+
+import * as React from "react";
import Box from "@mui/material/Box";

import { Video } from "../video";
import { VideoDetails } from "./VideoDetails";
+import { VideoPlayer } from "./VideoPlayer";
import { VideoThumbnail } from "./VideoThumbnail";

interface Props {
  video: Video;
}

export function MediaItem({ video }: Props) {
+ const [isHovered, setIsHovered] = React.useState(false);
+
+ const [isMuted, setIsMuted] = React.useState(true);
+ const toggleMute = React.useCallback(() => {
+   setIsMuted((prev) => !prev);
+ }, []);
+
  return (
    <Box
      sx={{
        width: "100%",
        mb: 2,
        display: "block",
        aspectRatio: "16 / 9",
      }}
+     onMouseEnter={() => setIsHovered(true)}
+     onMouseLeave={() => setIsHovered(false)}
    >
+     {isHovered ? (
+       <VideoPlayer video={video} isMuted={isMuted} toggleMute={toggleMute} />
+     ) : (
        <VideoThumbnail video={video} />
+     )}      
      <VideoDetails video={video} />
    </Box>
  );
}
Enter fullscreen mode Exit fullscreen mode

Video player

GitHub: feat(home): video player (#8)

Bonus

Next.js page

It seems that the name of our page model conflicts with Netx.js page.

app/Home/page.ts
Type error: Page "app/Home/page.ts" does not match the required types of a Next.js Page.

Let's rename web/app/Home/page.ts file to web/app/Home/pagination.ts. It should fix the issue.

GitHub: fix(home): model/nextjs page conflict (#7)

Masthead position bug fix

When we scroll down our home page - the masthead doesn't stay in its position. We didn't notice it before (in the last episode), because we didn't have enough content on the page. It turns out that we should have used sticky instead of static position.

web/app/AppShell/Masthead/Masthead.tsx (diff)

export function Masthead({ onGuideButtonClick, showGuideButton }: Props) {
  return (
-   <AppBar elevation={0} position="static">
+   <AppBar elevation={0} position="sticky">
      <Toolbar>
        {showGuideButton && <GuideButton onClick={onGuideButtonClick} />}
        <Logo />
      </Toolbar>
    </AppBar>
  );
}
Enter fullscreen mode Exit fullscreen mode

GitHub: fix(masthead): sticky position

Comments 0 total

    Add comment