Watch Page with Video and Metadata - CodeTube #7
In the previous episodes, we built a solid foundation for our YouTube clone — a responsive home page with a video grid (#4), a REST API with API Gateway, Lambda, and Aurora (#5), and a GraphQL API with AppSync, Lambda, and DynamoDB (#6).
Now it’s time to build the watch page — the page users land on when they click a video. This is one of the most important pages in any video platform. We need to display the video player, video metadata (title, description, channel info, publish date), and wire up navigation from the home page.
We’ll also need to adjust our responsive layout — the watch page has a different layout than the home page, with no mini guide sidebar and a narrower content area.
Let’s dive in!
Requirements
Gherkin
YouTube
Frontend
Watch page
web/app/watch/[videoId]/page.tsx
import { WatchPage } from "./WatchPage";
export default function Watch() {
return <WatchPage />;
}web/app/watch/[videoId]/WatchPage.tsx
"use client";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useParams } from "next/navigation";
export function WatchPage() {
const params = useParams();
const videoId = params.videoId as string;
return (
<Stack>
<Typography component="h2" variant="h4">
TODO: Watch Page
</Typography>
<Typography variant="caption">Video ID: {videoId}</Typography>
</Stack>
);
}Navigation from Home
We need to link each video thumbnail on the home page to the watch page. We wrap the MediaItem component with a NextLink so clicking a video navigates to /watch/{videoId}.
web/app/Home/MediaItem/MediaItem.tsx (diff)
"use client";
import * as React from "react";
import Box from "@mui/material/Box";
+import NextLink from "next/link";
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",
textDecoration: "none",
}}
+ component={NextLink}
+ href={`/watch/${video.id}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{isHovered ? (
<VideoPlayer video={video} isMuted={isMuted} toggleMute={toggleMute} />
) : (
<VideoThumbnail video={video} />
)}
<VideoDetails video={video} />
</Box>
);
}We also fix the video title color to use textPrimary so it’s visible in both light and dark themes.
web/app/Home/MediaItem/VideoDetails/VideoDetails.tsx (diff)
...
<Typography
variant="subtitle2"
component="h3"
fontSize={16}
+ color="textPrimary"
gutterBottom
mb={0.5}
>
{video.title}
</Typography>
...Responsive layout
The watch page has a different layout than the home page. On YouTube, when you navigate to a video:
- The mini guide disappears (no sidebar icons)
- The drawer is always temporary (slides over content, not persistent)
- The content area is narrower than the home page
We implement this by checking which page is active and adjusting the navigation accordingly.
web/app/AppShell/useNavigation.tsx (diff)
import useMediaQuery from "@mui/material/useMediaQuery";
import useTheme from "@mui/system/useTheme";
+import { useActive } from "./useActive";
export interface Navigation {
pivotBar?: boolean;
miniGuide?: boolean;
guide?: boolean;
variant?: "temporary" | "permanent";
}
export function useNavigation(opened: boolean): Navigation {
const theme = useTheme();
const tablet = useMediaQuery(theme.breakpoints.between("sm", "lg"));
const desktop = useMediaQuery(theme.breakpoints.up("lg"));
const home = useActive("/");
const navigation: Navigation = {
pivotBar: true,
miniGuide: false,
guide: false,
};
if (tablet) {
+ if (home) {
return { guide: true, miniGuide: true, variant: "temporary" };
+ } else {
+ return { guide: true, miniGuide: false, variant: "temporary" };
+ }
}
if (desktop) {
+ if (home) {
return opened
? { guide: true, variant: "permanent" }
: { guide: false, miniGuide: true, variant: "temporary" };
+ } else {
+ return { guide: true, miniGuide: false, variant: "temporary" };
+ }
}
return navigation;
}The PageManager also adjusts — the home page uses the full xxxl width for the video grid, while other pages (like watch) use the narrower xxl width.
web/app/AppShell/PageManager/PageManager.tsx (diff)
import Box from "@mui/material/Box";
import Container from "@mui/material/Container";
+import { Breakpoint } from "@mui/material/styles";
import { Navigation } from "../useNavigation";
+import { useActive } from "../useActive";
interface Props {
navigation: Navigation;
children: React.ReactNode;
}
export function PageManager({ navigation, children }: Props) {
+ let maxWidth: Breakpoint = "xxl";
let ml = 0;
let px = 0;
+ const home = useActive("/");
+ if (home) {
+ maxWidth = "xxxl";
+ }
if (navigation.guide && navigation.variant === "permanent") {
ml = 30;
px = 3;
}
if (navigation.miniGuide) {
ml = 9;
px = 3;
}
return (
- <Container maxWidth={"xxxl"} disableGutters>
+ <Container maxWidth={maxWidth} disableGutters>
<Box
sx={{
ml,
mt: 1,
px,
}}
>
{children}
</Box>
</Container>
);
}