Repository: jason-liu22/netflix-clone-react-typescript
Branch: main
Commit: 0a0ad44894ab
Files: 82
Total size: 108.0 KB
Directory structure:
gitextract_evgpsfy5/
├── .dockerignore
├── .firebaserc
├── .github/
│ └── workflows/
│ ├── firebase-hosting-merge.yml
│ └── firebase-hosting-pull-request.yml
├── .gitignore
├── Dockerfile
├── README.md
├── firebase.json
├── index.html
├── package.json
├── public/
│ ├── index.html
│ ├── manifest.json
│ └── robots.txt
├── src/
│ ├── CustomClassNameSetup.ts
│ ├── components/
│ │ ├── AgeLimitChip.tsx
│ │ ├── DetailModal.tsx
│ │ ├── GenreBreadcrumbs.tsx
│ │ ├── GridPage.tsx
│ │ ├── GridWithInfiniteScroll.tsx
│ │ ├── HeroSection.tsx
│ │ ├── Logo.tsx
│ │ ├── MainLoadingScreen.tsx
│ │ ├── MaturityRate.tsx
│ │ ├── MaxLineTypography.tsx
│ │ ├── MoreInfoButton.tsx
│ │ ├── NetflixIconButton.tsx
│ │ ├── NetflixNavigationLink.tsx
│ │ ├── PlayButton.tsx
│ │ ├── QualityChip.tsx
│ │ ├── SearchBox.tsx
│ │ ├── SimilarVideoCard.tsx
│ │ ├── VideoCardPortal.tsx
│ │ ├── VideoItemWithHover.tsx
│ │ ├── VideoItemWithHoverPure.tsx
│ │ ├── VideoPortalContainer.tsx
│ │ ├── VideoSlider.tsx
│ │ ├── animate/
│ │ │ ├── MotionContainer.tsx
│ │ │ └── variants/
│ │ │ ├── Wrap.ts
│ │ │ ├── fade/
│ │ │ │ ├── FadeIn.ts
│ │ │ │ └── FadeOut.ts
│ │ │ └── zoom/
│ │ │ └── ZoomIn.ts
│ │ ├── layouts/
│ │ │ ├── Footer.tsx
│ │ │ ├── MainHeader.tsx
│ │ │ └── index.ts
│ │ ├── slick-slider/
│ │ │ ├── CustomNavigation.tsx
│ │ │ └── SlickSlider.tsx
│ │ └── watch/
│ │ ├── PlayerControlButton.tsx
│ │ ├── PlayerSeekbar.tsx
│ │ ├── VideoJSPlayer.tsx
│ │ └── VolumeControllers.tsx
│ ├── constant/
│ │ └── index.ts
│ ├── hoc/
│ │ └── withPagination.tsx
│ ├── hooks/
│ │ ├── redux.ts
│ │ ├── useIntersectionObserver.ts
│ │ ├── useOffSetTop.ts
│ │ └── useWindowSize.ts
│ ├── layouts/
│ │ └── MainLayout.tsx
│ ├── lib/
│ │ └── createSafeContext.ts
│ ├── main.tsx
│ ├── pages/
│ │ ├── GenreExplore.tsx
│ │ ├── HomePage.tsx
│ │ └── WatchPage.tsx
│ ├── providers/
│ │ ├── DetailModalProvider.tsx
│ │ └── PortalProvider.tsx
│ ├── routes/
│ │ └── index.tsx
│ ├── store/
│ │ ├── index.ts
│ │ └── slices/
│ │ ├── apiSlice.ts
│ │ ├── configuration.ts
│ │ ├── discover.ts
│ │ └── genre.ts
│ ├── theme/
│ │ └── palette.ts
│ ├── types/
│ │ ├── Common.ts
│ │ ├── Genre.ts
│ │ └── Movie.ts
│ ├── utils/
│ │ ├── common.ts
│ │ └── index.ts
│ ├── videojs-youtube.d.ts
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
├── vercel.json
└── vite.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
.*
build
node_modules
================================================
FILE: .firebaserc
================================================
{
"projects": {
"default": "websites-f0426"
}
}
================================================
FILE: .github/workflows/firebase-hosting-merge.yml
================================================
# This file was auto-generated by the Firebase CLI
# https://github.com/firebase/firebase-tools
name: Deploy to Firebase Hosting on merge
'on':
push:
branches:
- main
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm ci && npm run build
env:
VITE_APP_API_ENDPOINT_URL: '${{ secrets.VITE_APP_API_ENDPOINT_URL }}'
VITE_APP_TMDB_V3_API_KEY: '${{ secrets.VITE_APP_TMDB_V3_API_KEY }}'
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: '${{ secrets.GITHUB_TOKEN }}'
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_WEBSITES_F0426 }}'
channelId: live
projectId: websites-f0426
================================================
FILE: .github/workflows/firebase-hosting-pull-request.yml
================================================
# This file was auto-generated by the Firebase CLI
# https://github.com/firebase/firebase-tools
name: Deploy to Firebase Hosting on PR
'on': pull_request
jobs:
build_and_preview:
if: '${{ github.event.pull_request.head.repo.full_name == github.repository }}'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm ci && npm run build
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: '${{ secrets.GITHUB_TOKEN }}'
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_WEBSITES_F0426 }}'
projectId: websites-f0426
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# env
.env
# testing
/coverage
# production
/build
/dist
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn.lock
yarn-debug.log*
yarn-error.log*
================================================
FILE: Dockerfile
================================================
FROM node:16.17.0-alpine as builder
WORKDIR /app
COPY ./package.json .
COPY ./yarn.lock .
RUN yarn install
COPY . .
ARG TMDB_V3_API_KEY
ENV VITE_APP_TMDB_V3_API_KEY=${TMDB_V3_API_KEY}
ENV VITE_APP_API_ENDPOINT_URL="https://api.themoviedb.org/3"
RUN yarn build
FROM nginx:stable-alpine
WORKDIR /usr/share/nginx/html
RUN rm -rf ./*
COPY --from=builder /app/dist .
EXPOSE 80
ENTRYPOINT ["nginx", "-g", "daemon off;"]
================================================
FILE: README.md
================================================
Table of Contents
Prerequests
Which features this project deals with
Third Party libraries used except for React and RTK
Contact
Home Page
Mini Portal
Detail Modal
Grid Genre Page
Watch Page with customer contol bar
## Prerequests
- Create an account if you don't have on [TMDB](https://www.themoviedb.org/).
Because I use its free API to consume movie/tv data.
- And then follow the [documentation](https://developers.themoviedb.org/3/getting-started/introduction) to create API Key
- Finally, if you use v3 of TMDB API, create a file named `.env`, and copy and paste the content of `.env.example`.
And then paste the API Key you just created.
## Which features this project deal with
- How to create and use [Custom Hooks](https://reactjs.org/docs/hooks-custom.html)
- How to use [Context](https://reactjs.org/docs/context.html) and its provider
- How to use lazy and Suspense for [Code-Splitting](https://reactjs.org/docs/code-splitting.html)
- How to use a new [lazy](https://reactrouter.com/en/main/route/lazy) feature of react-router to reduce bundle size.
- How to use data [loader](https://reactrouter.com/en/main/route/loader) of react-router, and how to use redux dispatch in the loader to fetch data before rendering component.
- How to use [Portal](https://reactjs.org/docs/portals.html)
- How to use [Fowarding Refs](https://reactjs.org/docs/forwarding-refs.html) to make components reusuable
- How to create and use [HOC](https://reactjs.org/docs/higher-order-components.html)
- How to customize default theme of [MUI](https://mui.com/)
- How to use [RTK](https://redux-toolkit.js.org/introduction/getting-started)
- How to use [RTK Query](https://redux-toolkit.js.org/rtk-query/overview)
- How to customize default classname of [MUI](https://mui.com/material-ui/experimental-api/classname-generator)
- Infinite Scrolling(using [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API))
- How to make awesome carousel using [slick-carousel](https://react-slick.neostack.com)
## Third Party libraries used except for React and RTK
- [react-router-dom@v6.9](https://reactrouter.com/en/main)
- [MUI(Material UI)](https://mui.com/)
- [framer-motion](https://www.framer.com/docs/)
- [video.js](https://videojs.com)
- [react-slick](https://react-slick.neostack.com/)
## Install with Docker
```sh
docker build --build-arg TMDB_V3_API_KEY=your_api_key_here -t netflix-clone .
docker run --name netflix-clone-website --rm -d -p 80:80 netflix-clone
```
## Todo
- Make the animation of video card portal more similar to Netflix.
- Improve performance. I am using `context` and `provider` but all components subscribed to the context's value are re-rendered. These re-renders happen even if the part of the value is not used in render of the component. there are [several ways](https://blog.axlight.com/posts/4-options-to-prevent-extra-rerenders-with-react-context/) to prevent the re-renders from these behaviours. In addition to them, there may be several performance issues.
- Replace bundler([Vite](https://vitejs.dev/guide)) with [Turbopack](https://turbo.build/pack/docs/why-turbopack). Turbopack is introduced in Next.js conf recently. It's very fast but it's nor ready to use right now. it just support Next.js, and they plan to support all others as soon as possible. so if it's ready to use, replace [Vite](https://vitejs.dev/guide) with [Turbopack](https://turbo.build/pack/docs/why-turbopack).
- Add accessibilities for better UX.
- Add Tests.
================================================
FILE: firebase.json
================================================
{
"hosting": {
"public": "dist",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}
================================================
FILE: index.html
================================================
Netflix
================================================
FILE: package.json
================================================
{
"name": "netflix-clone-using-react-typescript-mui",
"version": "0.1.1",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.10.0",
"@emotion/styled": "^11.10.0",
"@mui/icons-material": "^5.8.4",
"@mui/material": "^5.10.0",
"@reduxjs/toolkit": "^1.8.3",
"framer-motion": "^7.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.2",
"react-router-dom": "^6.9.0",
"react-slick": "^0.29.0",
"slick-carousel": "^1.8.1",
"video.js": "^8.3.0",
"videojs-youtube": "^3.0.1",
"vite-tsconfig-paths": "^3.5.2"
},
"devDependencies": {
"@types/react": "^18.0.24",
"@types/react-dom": "^18.0.8",
"@types/react-slick": "^0.23.10",
"@vitejs/plugin-react": "^2.2.0",
"typescript": "^4.6.4",
"vite": "^3.2.1"
}
}
================================================
FILE: public/index.html
================================================
Netflix
You need to enable JavaScript to run this app.
================================================
FILE: public/manifest.json
================================================
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
================================================
FILE: public/robots.txt
================================================
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
================================================
FILE: src/CustomClassNameSetup.ts
================================================
import { unstable_ClassNameGenerator as ClassNameGenerator } from "@mui/material/className";
ClassNameGenerator.configure((componentName) => {
let newComponentName = componentName;
// you can replace Mui, default prefix of every component with new one you want
newComponentName = newComponentName.replace("Mui", "Netflix");
// you can replace default classname of component with new one you want
newComponentName = newComponentName.replace("Button", "Btn");
return newComponentName;
});
================================================
FILE: src/components/AgeLimitChip.tsx
================================================
import Chip, { ChipProps } from "@mui/material/Chip";
export default function AgeLimitChip({ sx, ...others }: ChipProps) {
return (
span": { p: 0 },
...sx,
}}
variant="outlined"
/>
);
}
================================================
FILE: src/components/DetailModal.tsx
================================================
import { forwardRef, useCallback, useRef, useState } from "react";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import Container from "@mui/material/Container";
import Stack from "@mui/material/Stack";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import Slide from "@mui/material/Slide";
import { TransitionProps } from "@mui/material/transitions";
import CloseIcon from "@mui/icons-material/Close";
import AddIcon from "@mui/icons-material/Add";
import ThumbUpOffAltIcon from "@mui/icons-material/ThumbUpOffAlt";
import VolumeUpIcon from "@mui/icons-material/VolumeUp";
import VolumeOffIcon from "@mui/icons-material/VolumeOff";
import Player from "video.js/dist/types/player";
import MaxLineTypography from "./MaxLineTypography";
import PlayButton from "./PlayButton";
import NetflixIconButton from "./NetflixIconButton";
import AgeLimitChip from "./AgeLimitChip";
import QualityChip from "./QualityChip";
import { formatMinuteToReadable, getRandomNumber } from "src/utils/common";
import SimilarVideoCard from "./SimilarVideoCard";
import { useDetailModal } from "src/providers/DetailModalProvider";
import { useGetSimilarVideosQuery } from "src/store/slices/discover";
import { MEDIA_TYPE } from "src/types/Common";
import VideoJSPlayer from "./watch/VideoJSPlayer";
const Transition = forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement;
},
ref: React.Ref
) {
return ;
});
export default function DetailModal() {
const { detail, setDetailType } = useDetailModal();
const { data: similarVideos } = useGetSimilarVideosQuery(
{ mediaType: detail.mediaType ?? MEDIA_TYPE.Movie, id: detail.id ?? 0 },
{ skip: !detail.id }
);
const playerRef = useRef(null);
const [muted, setMuted] = useState(true);
const handleReady = useCallback((player: Player) => {
playerRef.current = player;
setMuted(player.muted());
}, []);
const handleMute = useCallback((status: boolean) => {
if (playerRef.current) {
playerRef.current.muted(!status);
setMuted(!status);
}
}, []);
if (detail.mediaDetail) {
return (
{
setDetailType({ mediaType: undefined, id: undefined });
}}
sx={{
top: 15,
right: 15,
position: "absolute",
bgcolor: "#181818",
width: { xs: 22, sm: 40 },
height: { xs: 22, sm: 40 },
"&:hover": {
bgcolor: "primary.main",
},
}}
>
{detail.mediaDetail?.title}
handleMute(muted)}
sx={{ zIndex: 2 }}
>
{!muted ? : }
{`${getRandomNumber(100)}% Match`}
{detail.mediaDetail?.release_date.substring(0, 4)}
{`${formatMinuteToReadable(
getRandomNumber(180)
)}`}
{detail.mediaDetail?.overview}
{`Genres : ${detail.mediaDetail?.genres
.map((g) => g.name)
.join(", ")}`}
{`Available in : ${detail.mediaDetail?.spoken_languages
.map((l) => l.name)
.join(", ")}`}
{similarVideos && similarVideos.results.length > 0 && (
More Like This
{similarVideos.results.map((sm) => (
))}
)}
);
}
return null;
}
================================================
FILE: src/components/GenreBreadcrumbs.tsx
================================================
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Breadcrumbs, { BreadcrumbsProps } from "@mui/material/Breadcrumbs";
const Separator = (
);
interface GenreBreadcrumbsProps extends BreadcrumbsProps {
genres: string[];
}
export default function GenreBreadcrumbs({
genres,
...others
}: GenreBreadcrumbsProps) {
return (
{genres.map((genre, idx) => (
{genre}
))}
);
}
================================================
FILE: src/components/GridPage.tsx
================================================
import withPagination from "src/hoc/withPagination";
import { MEDIA_TYPE } from "src/types/Common";
import { CustomGenre, Genre } from "src/types/Genre";
import GridWithInfiniteScroll from "./GridWithInfiniteScroll";
interface GridPageProps {
genre: Genre | CustomGenre;
mediaType: MEDIA_TYPE;
}
export default function GridPage({ genre, mediaType }: GridPageProps) {
const Component = withPagination(
GridWithInfiniteScroll,
mediaType,
genre
);
return ;
}
================================================
FILE: src/components/GridWithInfiniteScroll.tsx
================================================
import { useRef, useEffect } from "react";
import Grid from "@mui/material/Grid";
import Box from "@mui/material/Box";
import Container from "@mui/material/Container";
import Typography from "@mui/material/Typography";
import VideoItemWithHover from "./VideoItemWithHover";
import { CustomGenre, Genre } from "src/types/Genre";
import { PaginatedMovieResult } from "src/types/Common";
import useIntersectionObserver from "src/hooks/useIntersectionObserver";
interface GridWithInfiniteScrollProps {
genre: Genre | CustomGenre;
data: PaginatedMovieResult;
handleNext: (page: number) => void;
}
export default function GridWithInfiniteScroll({
genre,
data,
handleNext,
}: GridWithInfiniteScrollProps) {
const intersectionRef = useRef(null);
const intersection = useIntersectionObserver(intersectionRef);
useEffect(() => {
if (
intersection &&
intersection.intersectionRatio === 1 &&
data.page < data.total_pages
) {
handleNext(data.page + 1);
}
}, [intersection]);
return (
<>
{`${genre.name} Movies`}
{data.results
.filter((v) => !!v.backdrop_path)
.map((video, idx) => (
))}
>
);
}
================================================
FILE: src/components/HeroSection.tsx
================================================
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import VolumeUpIcon from "@mui/icons-material/VolumeUp";
import VolumeOffIcon from "@mui/icons-material/VolumeOff";
import Player from "video.js/dist/types/player";
import { getRandomNumber } from "src/utils/common";
import MaxLineTypography from "./MaxLineTypography";
import PlayButton from "./PlayButton";
import MoreInfoButton from "./MoreInfoButton";
import NetflixIconButton from "./NetflixIconButton";
import MaturityRate from "./MaturityRate";
import useOffSetTop from "src/hooks/useOffSetTop";
import { useDetailModal } from "src/providers/DetailModalProvider";
import { MEDIA_TYPE } from "src/types/Common";
import {
useGetVideosByMediaTypeAndCustomGenreQuery,
useLazyGetAppendedVideosQuery,
} from "src/store/slices/discover";
import { Movie } from "src/types/Movie";
import VideoJSPlayer from "./watch/VideoJSPlayer";
interface TopTrailerProps {
mediaType: MEDIA_TYPE;
}
export default function TopTrailer({ mediaType }: TopTrailerProps) {
const { data } = useGetVideosByMediaTypeAndCustomGenreQuery({
mediaType,
apiString: "popular",
page: 1,
});
const [getVideoDetail, { data: detail }] = useLazyGetAppendedVideosQuery();
const [video, setVideo] = useState(null);
const [muted, setMuted] = useState(true);
const playerRef = useRef(null);
const isOffset = useOffSetTop(window.innerWidth * 0.5625);
const { setDetailType } = useDetailModal();
const maturityRate = useMemo(() => {
return getRandomNumber(20);
}, []);
const handleReady = useCallback((player: Player) => {
playerRef.current = player;
}, []);
useEffect(() => {
if (playerRef.current) {
if (isOffset) {
playerRef.current.pause();
} else {
if (playerRef.current.paused()) {
playerRef.current.play();
}
}
}
}, [isOffset]);
useEffect(() => {
if (data && data.results) {
const videos = data.results.filter((item) => !!item.backdrop_path);
setVideo(videos[getRandomNumber(videos.length)]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useEffect(() => {
if (video) {
getVideoDetail({ mediaType, id: video.id });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [video]);
const handleMute = useCallback((status: boolean) => {
if (playerRef.current) {
playerRef.current.muted(!status);
setMuted(!status);
}
}, []);
return (
{video && (
<>
{detail && (
)}
handleMute(muted)}
sx={{ zIndex: 2 }}
>
{!muted ? : }
{`${maturityRate}+`}
{video.title}
{video.overview}
{
setDetailType({ mediaType, id: video.id });
}}
/>
>
)}
);
}
================================================
FILE: src/components/Logo.tsx
================================================
import Box, { BoxProps } from "@mui/material/Box";
import { Link as RouterLink } from "react-router-dom";
import { MAIN_PATH } from "src/constant";
export default function Logo({ sx }: BoxProps) {
return (
);
}
================================================
FILE: src/components/MainLoadingScreen.tsx
================================================
import CircularProgress from "@mui/material/CircularProgress";
function MainLoadingScreen() {
return (
);
}
export default MainLoadingScreen;
================================================
FILE: src/components/MaturityRate.tsx
================================================
import Box from "@mui/material/Box";
import { ReactNode } from "react";
export default function MaturityRate({ children }: { children: ReactNode }) {
return (
{children}
);
}
================================================
FILE: src/components/MaxLineTypography.tsx
================================================
import { forwardRef } from "react";
import Typography, { TypographyProps } from "@mui/material/Typography";
const MaxLineTypography = forwardRef<
HTMLDivElement,
TypographyProps & { maxLine: number }
>(({ maxLine, children, sx, ...others }, ref) => {
return (
{children}
);
});
export default MaxLineTypography;
================================================
FILE: src/components/MoreInfoButton.tsx
================================================
import Button, { ButtonProps } from "@mui/material/Button";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
export default function MoreInfoButton({ sx, ...others }: ButtonProps) {
return (
}
{...others}
sx={{
...sx,
px: { xs: 1, sm: 2 },
py: { xs: 0.5, sm: 1 },
fontSize: { xs: 18, sm: 24, md: 28 },
lineHeight: 1.5,
fontWeight: "bold",
textTransform: "capitalize",
bgcolor: "#6d6d6eb3",
whiteSpace: "nowrap",
"&:hover": { bgcolor: "#6d6d6e66" },
}}
>
More Info
);
}
================================================
FILE: src/components/NetflixIconButton.tsx
================================================
import { forwardRef } from "react";
import IconButton, { IconButtonProps } from "@mui/material/IconButton";
const NetflixIconButton = forwardRef(
({ children, sx, ...others }, ref) => {
return (
{children}
);
}
);
export default NetflixIconButton;
================================================
FILE: src/components/NetflixNavigationLink.tsx
================================================
import {
Link as RouterLink,
LinkProps as RouterLinkProps,
} from "react-router-dom";
import Link, { LinkProps } from "@mui/material/Link";
export default function NetflixNavigationLink({
sx,
children,
...others
}: LinkProps & RouterLinkProps) {
return (
{children}
);
}
================================================
FILE: src/components/PlayButton.tsx
================================================
import Button, { ButtonProps } from "@mui/material/Button";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import { useNavigate } from "react-router-dom";
import { MAIN_PATH } from "src/constant";
export default function PlayButton({ sx, ...others }: ButtonProps) {
const navigate = useNavigate();
return (
}
{...others}
sx={{
px: { xs: 1, sm: 2 },
py: { xs: 0.5, sm: 1 },
fontSize: { xs: 18, sm: 24, md: 28 },
lineHeight: 1.5,
fontWeight: "bold",
whiteSpace: "nowrap",
textTransform: "capitalize",
...sx,
}}
onClick={() => navigate(`/${MAIN_PATH.watch}`)}
>
Play
);
}
================================================
FILE: src/components/QualityChip.tsx
================================================
import Chip, { ChipProps } from "@mui/material/Chip";
export default function QualityChip({ sx, ...others }: ChipProps) {
return (
span": { p: 0 },
...sx,
}}
/>
);
}
================================================
FILE: src/components/SearchBox.tsx
================================================
import { useState, useRef } from "react";
import { styled } from "@mui/material/styles";
import InputBase from "@mui/material/InputBase";
import SearchIcon from "@mui/icons-material/Search";
const Search = styled("div")(({ theme }) => ({
position: "relative",
width: "100%",
display: "flex",
alignItems: "center",
}));
const SearchIconWrapper = styled("div")(({ theme }) => ({
cursor: "pointer",
padding: theme.spacing(0, 1),
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}));
const StyledInputBase = styled(InputBase)(({ theme }) => ({
color: "inherit",
"& .NetflixInputBase-input": {
width: 0,
transition: theme.transitions.create("width", {
duration: theme.transitions.duration.complex,
easing: theme.transitions.easing.easeIn,
}),
"&:focus": {
width: "auto",
},
},
}));
export default function SearchBox() {
const [isFocused, setIsFocused] = useState(false);
const searchInputRef = useRef();
const handleClickSearchIcon = () => {
if (!isFocused) {
searchInputRef.current?.focus();
}
};
return (
{
setIsFocused(true);
},
onBlur: () => {
setIsFocused(false);
},
}}
/>
);
}
================================================
FILE: src/components/SimilarVideoCard.tsx
================================================
import Stack from "@mui/material/Stack";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Typography from "@mui/material/Typography";
import AddIcon from "@mui/icons-material/Add";
import { Movie } from "src/types/Movie";
import NetflixIconButton from "./NetflixIconButton";
import MaxLineTypography from "./MaxLineTypography";
import { formatMinuteToReadable, getRandomNumber } from "src/utils/common";
import AgeLimitChip from "./AgeLimitChip";
import { useGetConfigurationQuery } from "src/store/slices/configuration";
interface SimilarVideoCardProps {
video: Movie;
}
export default function SimilarVideoCard({ video }: SimilarVideoCardProps) {
const { data: configuration } = useGetConfigurationQuery(undefined);
return (
{`${formatMinuteToReadable(
getRandomNumber(180)
)}`}
{video.title}
{`${getRandomNumber(100)}% Match`}
{video.release_date.substring(0, 4)}
{video.overview}
);
}
================================================
FILE: src/components/VideoCardPortal.tsx
================================================
import { useNavigate } from "react-router-dom";
import Stack from "@mui/material/Stack";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Typography from "@mui/material/Typography";
import VolumeUpIcon from "@mui/icons-material/VolumeUp";
import PlayCircleIcon from "@mui/icons-material/PlayCircle";
import ThumbUpOffAltIcon from "@mui/icons-material/ThumbUpOffAlt";
import AddIcon from "@mui/icons-material/Add";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { Movie } from "src/types/Movie";
import { usePortal } from "src/providers/PortalProvider";
import { useDetailModal } from "src/providers/DetailModalProvider";
import { formatMinuteToReadable, getRandomNumber } from "src/utils/common";
import NetflixIconButton from "./NetflixIconButton";
import MaxLineTypography from "./MaxLineTypography";
import AgeLimitChip from "./AgeLimitChip";
import QualityChip from "./QualityChip";
import GenreBreadcrumbs from "./GenreBreadcrumbs";
import { useGetConfigurationQuery } from "src/store/slices/configuration";
import { MEDIA_TYPE } from "src/types/Common";
import { useGetGenresQuery } from "src/store/slices/genre";
import { MAIN_PATH } from "src/constant";
interface VideoCardModalProps {
video: Movie;
anchorElement: HTMLElement;
}
export default function VideoCardModal({
video,
anchorElement,
}: VideoCardModalProps) {
const navigate = useNavigate();
const { data: configuration } = useGetConfigurationQuery(undefined);
const { data: genres } = useGetGenresQuery(MEDIA_TYPE.Movie);
const setPortal = usePortal();
const rect = anchorElement.getBoundingClientRect();
const { setDetailType } = useDetailModal();
return (
{
setPortal(null, null);
}}
sx={{
width: rect.width * 1.5,
height: "100%",
}}
>
navigate(`/${MAIN_PATH.watch}`)}
>
{
setDetailType({ mediaType: MEDIA_TYPE.Movie, id: video.id });
}}
>
{`${getRandomNumber(100)}% Match`}
{`${formatMinuteToReadable(
getRandomNumber(180)
)}`}
{genres && (
video.genre_ids.includes(genre.id))
.map((genre) => genre.name)}
/>
)}
);
}
================================================
FILE: src/components/VideoItemWithHover.tsx
================================================
import { useEffect, useState, useRef } from "react";
import { Movie } from "src/types/Movie";
import { usePortal } from "src/providers/PortalProvider";
import { useGetConfigurationQuery } from "src/store/slices/configuration";
import VideoItemWithHoverPure from "./VideoItemWithHoverPure";
interface VideoItemWithHoverProps {
video: Movie;
}
export default function VideoItemWithHover({ video }: VideoItemWithHoverProps) {
const setPortal = usePortal();
const elementRef = useRef(null);
const [isHovered, setIsHovered] = useState(false);
const { data: configuration } = useGetConfigurationQuery(undefined);
useEffect(() => {
if (isHovered) {
setPortal(elementRef.current, video);
}
}, [isHovered]);
return (
);
}
================================================
FILE: src/components/VideoItemWithHoverPure.tsx
================================================
import { PureComponent, ForwardedRef, forwardRef } from "react";
type VideoItemWithHoverPureType = {
src: string;
innerRef: ForwardedRef;
handleHover: (value: boolean) => void;
};
class VideoItemWithHoverPure extends PureComponent {
render() {
return (
{
// console.log("onPointerEnter");
this.props.handleHover(true);
}}
onPointerLeave={() => {
// console.log("onPointerLeave");
this.props.handleHover(false);
}}
/>
);
}
}
const VideoItemWithHoverRef = forwardRef<
HTMLDivElement,
Omit
>((props, ref) => );
VideoItemWithHoverRef.displayName = "VideoItemWithHoverRef";
export default VideoItemWithHoverRef;
================================================
FILE: src/components/VideoPortalContainer.tsx
================================================
import { useRef } from "react";
import { motion } from "framer-motion";
import Portal from "@mui/material/Portal";
import VideoCardPortal from "./VideoCardPortal";
import MotionContainer from "./animate/MotionContainer";
import {
varZoomIn,
varZoomInLeft,
varZoomInRight,
} from "./animate/variants/zoom/ZoomIn";
import { usePortalData } from "src/providers/PortalProvider";
export default function VideoPortalContainer() {
const { miniModalMediaData, anchorElement } = usePortalData();
const container = useRef(null);
const rect = anchorElement?.getBoundingClientRect();
const hasToRender = !!miniModalMediaData && !!anchorElement;
let isFirstElement = false;
let isLastElement = false;
let variant = varZoomIn;
if (hasToRender) {
const parentElement = anchorElement.closest(".slick-active");
const nextSiblingOfParentElement = parentElement?.nextElementSibling;
const previousSiblingOfParentElement =
parentElement?.previousElementSibling;
if (!previousSiblingOfParentElement?.classList.contains("slick-active")) {
isFirstElement = true;
variant = varZoomInLeft;
} else if (
!nextSiblingOfParentElement?.classList.contains("slick-active")
) {
isLastElement = true;
variant = varZoomInRight;
}
}
return (
<>
{hasToRender && (
)}
>
);
}
================================================
FILE: src/components/VideoSlider.tsx
================================================
import withPagination from "src/hoc/withPagination";
import { MEDIA_TYPE } from "src/types/Common";
import { CustomGenre, Genre } from "src/types/Genre";
import SlickSlider from "./slick-slider/SlickSlider";
interface SliderRowForGenreProps {
genre: Genre | CustomGenre;
mediaType: MEDIA_TYPE;
}
export default function SliderRowForGenre({
genre,
mediaType,
}: SliderRowForGenreProps) {
const Component = withPagination(SlickSlider, mediaType, genre);
return ;
}
================================================
FILE: src/components/animate/MotionContainer.tsx
================================================
import { motion } from "framer-motion";
import Box, { BoxProps } from "@mui/material/Box";
import { varWrapBoth } from "./variants/Wrap";
interface MotionContainerProps extends BoxProps {
initial?: boolean | string;
open?: boolean;
}
export default function MotionContainer({
open,
children,
...other
}: MotionContainerProps) {
return (
{children}
);
}
================================================
FILE: src/components/animate/variants/Wrap.ts
================================================
export const varWrapEnter = {
animate: {
transition: { staggerChildren: 0.1 },
},
};
export const varWrapExit = {
exit: {
transition: { staggerChildren: 0.1 },
},
};
export const varWrapBoth = {
animate: {
transition: { staggerChildren: 0.07, delayChildren: 0.1 },
},
exit: {
transition: { staggerChildren: 0.05, staggerDirection: -1 },
},
};
================================================
FILE: src/components/animate/variants/fade/FadeIn.ts
================================================
const TRANSITION_ENTER = {
duration: 0.64,
ease: [0.43, 0.13, 0.23, 0.96],
};
const TRANSITION_EXIT = {
duration: 0.48,
ease: [0.43, 0.13, 0.23, 0.96],
};
export const varFadeIn = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: TRANSITION_ENTER },
exit: { opacity: 0, transition: TRANSITION_EXIT },
};
================================================
FILE: src/components/animate/variants/fade/FadeOut.ts
================================================
const TRANSITION_ENTER = {
duration: 0.64,
ease: [0.43, 0.13, 0.23, 0.96],
};
const TRANSITION_EXIT = {
duration: 0.48,
ease: [0.43, 0.13, 0.23, 0.96],
};
export const varFadeOut = {
initial: { opacity: 1 },
animate: { opacity: 0, transition: TRANSITION_ENTER },
exit: { opacity: 1, transition: TRANSITION_EXIT },
};
================================================
FILE: src/components/animate/variants/zoom/ZoomIn.ts
================================================
const DISTANCE = 0;
const IN = { scale: 1, opacity: 1 };
const OUT = { scale: 0, opacity: 0 };
const TRANSITION_ENTER = {
duration: 1,
ease: [0.43, 0.13, 0.23, 0.96],
};
const TRANSITION_EXIT = {
duration: 1,
ease: [0.43, 0.13, 0.23, 0.96],
};
export const varZoomIn = {
initial: OUT,
animate: { ...IN, transition: TRANSITION_ENTER },
exit: { ...OUT, transition: TRANSITION_EXIT },
};
export const varZoomInLeft = {
initial: { ...OUT, translateX: -DISTANCE },
animate: { ...IN, translateX: 0, transition: TRANSITION_ENTER },
exit: { ...OUT, translateX: -DISTANCE, transition: TRANSITION_EXIT },
};
export const varZoomInRight = {
initial: { ...OUT, translateX: DISTANCE },
animate: { ...IN, translateX: 0, transition: TRANSITION_ENTER },
exit: { ...OUT, translateX: DISTANCE, transition: TRANSITION_EXIT },
};
================================================
FILE: src/components/layouts/Footer.tsx
================================================
import Box from "@mui/material/Box";
import Link from "@mui/material/Link";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
export default function Footer() {
return (
Developed by{" "}
Crazy Man
);
}
================================================
FILE: src/components/layouts/MainHeader.tsx
================================================
import * as React from "react";
import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import Menu from "@mui/material/Menu";
import MenuIcon from "@mui/icons-material/Menu";
import Avatar from "@mui/material/Avatar";
import Tooltip from "@mui/material/Tooltip";
import MenuItem from "@mui/material/MenuItem";
import useOffSetTop from "src/hooks/useOffSetTop";
import { APP_BAR_HEIGHT } from "src/constant";
import Logo from "../Logo";
import SearchBox from "../SearchBox";
import NetflixNavigationLink from "../NetflixNavigationLink";
const pages = ["My List", "Movies", "Tv Shows"];
const MainHeader = () => {
const isOffset = useOffSetTop(APP_BAR_HEIGHT);
const [anchorElNav, setAnchorElNav] = React.useState(
null
);
const [anchorElUser, setAnchorElUser] = React.useState(
null
);
const handleOpenNavMenu = (event: React.MouseEvent) => {
setAnchorElNav(event.currentTarget);
};
const handleOpenUserMenu = (event: React.MouseEvent) => {
setAnchorElUser(event.currentTarget);
};
const handleCloseNavMenu = () => {
setAnchorElNav(null);
};
const handleCloseUserMenu = () => {
setAnchorElUser(null);
};
return (
theme.shadows[4],
}
: { boxShadow: 0, bgcolor: "transparent" }),
}}
>
Netflix
{pages.map((page) => (
{page}
))}
);
};
export default MainHeader;
================================================
FILE: src/components/layouts/index.ts
================================================
export { default as MainHeader } from "./MainHeader";
export { default as Footer } from "./Footer";
================================================
FILE: src/components/slick-slider/CustomNavigation.tsx
================================================
import { styled } from "@mui/material/styles";
import Box from "@mui/material/Box";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import { MouseEventHandler, ReactNode } from "react";
const ArrowStyle = styled(Box)(({ theme }) => ({
top: 0,
bottom: 0,
position: "absolute",
zIndex: 9,
height: "100%",
opacity: 0.48,
display: "flex",
cursor: "pointer",
alignItems: "center",
justifyContent: "center",
color: theme.palette.common.white,
// background: theme.palette.grey[700],
transition: theme.transitions.create("opacity"),
"&:hover": {
opacity: 0.8,
background: theme.palette.grey[900],
},
[theme.breakpoints.down("sm")]: {
display: "none",
},
}));
interface CustomNaviationProps {
isEnd: boolean;
arrowWidth: number;
children: ReactNode;
activeSlideIndex: number;
onNext: MouseEventHandler;
onPrevious: MouseEventHandler;
}
export default function CustomNavigation({
isEnd,
onNext,
children,
onPrevious,
arrowWidth,
activeSlideIndex,
}: CustomNaviationProps) {
return (
<>
{activeSlideIndex > 0 && (
// `linear-gradient(to right, ${theme.palette.background.default} 0%, rgba(0,0,0,0) 100%)`,
}}
>
)}
{children}
{!isEnd && (
// `linear-gradient(to left, ${theme.palette.background.default} 0%, rgba(0,0,0,0) 100%)`,
}}
>
)}
>
);
}
================================================
FILE: src/components/slick-slider/SlickSlider.tsx
================================================
import { useState, useRef } from "react";
import Slider, { Settings } from "react-slick";
import { motion } from "framer-motion";
import { styled, Theme, useTheme } from "@mui/material/styles";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import CustomNavigation from "./CustomNavigation";
import VideoItemWithHover from "src/components/VideoItemWithHover";
import { ARROW_MAX_WIDTH } from "src/constant";
import NetflixNavigationLink from "src/components/NetflixNavigationLink";
import MotionContainer from "src/components/animate/MotionContainer";
import { varFadeIn } from "src/components/animate/variants/fade/FadeIn";
import { CustomGenre, Genre } from "src/types/Genre";
import { Movie } from "src/types/Movie";
import { PaginatedMovieResult } from "src/types/Common";
const RootStyle = styled("div")(() => ({
position: "relative",
overflow: "inherit",
}));
const StyledSlider = styled(Slider)(
({ theme, padding }: { theme: Theme; padding: number }) => ({
display: "flex !important",
justifyContent: "center",
overflow: "initial !important",
"& > .slick-list": {
overflow: "visible",
},
[theme.breakpoints.up("sm")]: {
"& > .slick-list": {
width: `calc(100% - ${2 * padding}px)`,
},
"& .slick-list > .slick-track": {
margin: "0px !important",
},
"& .slick-list > .slick-track > .slick-current > div > .NetflixBox-root > .NetflixPaper-root:hover":
{
transformOrigin: "0% 50% !important",
},
},
[theme.breakpoints.down("sm")]: {
"& > .slick-list": {
width: `calc(100% - ${padding}px)`,
},
},
})
);
interface SlideItemProps {
item: Movie;
}
function SlideItem({ item }: SlideItemProps) {
return (
);
}
interface SlickSliderProps {
data: PaginatedMovieResult;
genre: Genre | CustomGenre;
handleNext: (page: number) => void;
}
export default function SlickSlider({ data, genre }: SlickSliderProps) {
const sliderRef = useRef(null);
const [activeSlideIndex, setActiveSlideIndex] = useState(0);
const [showExplore, setShowExplore] = useState(false);
const [isEnd, setIsEnd] = useState(false);
const theme = useTheme();
const beforeChange = async (currentIndex: number, nextIndex: number) => {
if (currentIndex < nextIndex) {
setActiveSlideIndex(nextIndex);
} else if (currentIndex > nextIndex) {
setIsEnd(false);
}
setActiveSlideIndex(nextIndex);
};
const settings: Settings = {
speed: 500,
arrows: false,
infinite: false,
lazyLoad: "ondemand",
slidesToShow: 6,
slidesToScroll: 6,
// afterChange: (current) => {
// console.log("After Change", current);
// },
beforeChange,
// onEdge: (direction) => {
// console.log("Edge: ", direction);
// },
responsive: [
{
breakpoint: 1536,
settings: {
slidesToShow: 5,
slidesToScroll: 5,
},
},
{
breakpoint: 1200,
settings: {
slidesToShow: 4,
slidesToScroll: 4,
},
},
{
breakpoint: 900,
settings: {
slidesToShow: 3,
slidesToScroll: 3,
},
},
{
breakpoint: 600,
settings: {
slidesToShow: 2,
slidesToScroll: 2,
},
},
],
};
const handlePrevious = () => {
sliderRef.current?.slickPrev();
};
const handleNext = () => {
sliderRef.current?.slickNext();
};
return (
{data.results.length > 0 && (
<>
{
setShowExplore(true);
}}
onMouseLeave={() => {
setShowExplore(false);
}}
>
{`${genre.name} Movies `}
{"Explore All".split("").map((letter, index) => (
{letter}
))}
{data.results
.filter((i) => !!i.backdrop_path)
.map((item) => (
))}
>
)}
);
}
================================================
FILE: src/components/watch/PlayerControlButton.tsx
================================================
import { forwardRef } from "react";
import IconButton, { IconButtonProps } from "@mui/material/IconButton";
const PlayerControlButton = forwardRef(
({ children, ...others }, ref) => (
{children}
)
);
export default PlayerControlButton;
================================================
FILE: src/components/watch/PlayerSeekbar.tsx
================================================
import Slider from "@mui/material/Slider";
import { styled } from "@mui/material/styles";
import { formatTime } from "src/utils/common";
const StyledSlider = styled(Slider)({
borderRadius: 0,
"& .NetflixSlider-track": {
backgroundColor: "red !important",
border: 0,
},
"& .NetflixSlider-rail": {
border: "none",
backgroundColor: "white !important",
opacity: 0.85,
},
"& .NetflixSlider-thumb": {
borderRadius: "50%",
height: 10,
width: 10,
backgroundColor: "red",
"&:focus, &:hover, &.Netflix-active, &.Netflix-focusVisible": {
boxShadow: "inherit",
height: 15,
width: 15,
},
"&:before": {
display: "none",
boxShadow: "0 2px 2px 0 #fff",
height: 10,
width: 10,
},
},
// "& .NetflixSlider-valueLabel": {
// lineHeight: 1.2,
// fontSize: 12,
// background: "unset",
// padding: 0,
// width: 32,
// height: 32,
// borderRadius: "50% 50% 50% 0",
// backgroundColor: "#52af77",
// transformOrigin: "bottom left",
// transform: "translate(50%, -100%) rotate(-45deg) scale(0)",
// "&:before": { display: "none" },
// "&.NetflixSlider-valueLabelOpen": {
// transform: "translate(50%, -100%) rotate(-45deg) scale(1)",
// },
// "& > *": {
// transform: "rotate(45deg)",
// },
// },
});
function PlayerSeekbar({
playedSeconds,
duration,
seekTo,
}: {
playedSeconds: number;
duration: number;
seekTo: (value: number) => void;
}) {
return (
formatTime(v)}
// components={{
// ValueLabel: ValueLabelComponent,
// }}
value={playedSeconds}
max={duration}
onChange={(_, value) => {
seekTo(value as number);
}}
/>
);
}
export default PlayerSeekbar;
================================================
FILE: src/components/watch/VideoJSPlayer.tsx
================================================
import { useEffect, useRef } from "react";
import Player from "video.js/dist/types/player";
import videojs from "video.js";
import "videojs-youtube";
import "video.js/dist/video-js.css";
export default function VideoJSPlayer({
options,
onReady,
}: {
options: any;
onReady: (player: Player) => void;
}) {
const videoRef = useRef(null);
const playerRef = useRef(null);
useEffect(() => {
(async function handleVideojs() {
// Make sure Video.js player is only initialized once
if (!playerRef.current) {
// The Video.js player needs to be _inside_ the component el for React 18 Strict Mode.
const videoElement = document.createElement("video-js");
// videoElement.classList.add("vjs-big-play-centered", "vjs-16-9");
videoRef.current?.appendChild(videoElement);
const player = (playerRef.current = videojs(
videoElement,
options,
() => {
onReady && onReady(player);
}
));
// import("video.js").then(async ({ default: videojs }) => {
// await import("video.js/dist/video-js.css");
// if (options["techOrder"] && options["techOrder"].includes("youtube")) {
// // eslint-disable-next-line
// await import("videojs-youtube");
// }
// const player = (playerRef.current = videojs(
// videoElement,
// options,
// () => {
// onReady && onReady(player);
// }
// ));
// });
// await import("video.js/dist/video-js.css");
// const videojs = await import("video.js");
// if (options["techOrder"] && options["techOrder"].includes("youtube")) {
// // eslint-disable-next-line
// await import("videojs-youtube");
// }
// const player = (playerRef.current = videojs.default(
// videoElement,
// options,
// () => {
// onReady && onReady(player);
// }
// ));
// You could update an existing player in the `else` block here
// on prop change, for example:
} else {
const player = playerRef.current;
// player.autoplay(options.autoplay);
player.width(options.width);
player.height(options.height);
}
})();
}, [options, videoRef]);
// Dispose the Video.js player when the functional component unmounts
useEffect(() => {
const player = playerRef.current;
return () => {
if (player && !player.isDisposed()) {
player.dispose();
playerRef.current = null;
}
};
}, [playerRef]);
return (
);
}
================================================
FILE: src/components/watch/VolumeControllers.tsx
================================================
import { Stack } from "@mui/material";
import Slider from "@mui/material/Slider";
import { styled } from "@mui/material/styles";
import { SliderUnstyledOwnProps } from "@mui/base/SliderUnstyled";
import VolumeUpIcon from "@mui/icons-material/VolumeUp";
import VolumeOffIcon from "@mui/icons-material/VolumeOff";
import PlayerControlButton from "./PlayerControlButton";
const StyledSlider = styled(Slider)({
height: 5,
borderRadius: 0,
padding: 0,
"& .NetflixSlider-track": {
border: "none",
backgroundColor: "red",
},
"& .NetflixSlider-rail": {
border: "none",
backgroundColor: "white",
opacity: 0.85,
},
"& .NetflixSlider-thumb": {
height: 10,
width: 10,
backgroundColor: "red",
"&:focus, &:hover, &.Netflix-active, &.Netflix-focusVisible": {
boxShadow: "inherit",
height: 15,
width: 15,
},
"&:before": {
display: "none",
},
},
});
export default function VolumeControllers({
value,
handleVolume,
handleVolumeToggle,
muted,
}: {
value: number;
handleVolume: SliderUnstyledOwnProps["onChange"];
handleVolumeToggle: React.MouseEventHandler;
muted: boolean;
}) {
return (
{!muted ? : }
x}
onChange={handleVolume}
sx={{ width: { xs: 60, sm: 80, md: 100 } }}
/>
);
}
================================================
FILE: src/constant/index.ts
================================================
import { CustomGenre } from "src/types/Genre";
export const API_ENDPOINT_URL = import.meta.env.VITE_APP_API_ENDPOINT_URL;
export const TMDB_V3_API_KEY = import.meta.env.VITE_APP_TMDB_V3_API_KEY;
export const MAIN_PATH = {
root: "",
browse: "browse",
genreExplore: "genre",
watch: "watch",
};
export const ARROW_MAX_WIDTH = 60;
export const COMMON_TITLES: CustomGenre[] = [
{ name: "Popular", apiString: "popular" },
{ name: "Top Rated", apiString: "top_rated" },
{ name: "Now Playing", apiString: "now_playing" },
{ name: "Upcoming", apiString: "upcoming" },
];
export const YOUTUBE_URL = "https://www.youtube.com/watch?v=";
export const APP_BAR_HEIGHT = 70;
export const INITIAL_DETAIL_STATE = {
id: undefined,
mediaType: undefined,
mediaDetail: undefined,
};
================================================
FILE: src/hoc/withPagination.tsx
================================================
import { ElementType, useCallback, useEffect } from "react";
import MainLoadingScreen from "src/components/MainLoadingScreen";
import { useAppDispatch, useAppSelector } from "src/hooks/redux";
import {
initiateItem,
useLazyGetVideosByMediaTypeAndGenreIdQuery,
useLazyGetVideosByMediaTypeAndCustomGenreQuery,
} from "src/store/slices/discover";
import { MEDIA_TYPE } from "src/types/Common";
import { CustomGenre, Genre } from "src/types/Genre";
export default function withPagination(
Component: ElementType,
mediaType: MEDIA_TYPE,
genre: Genre | CustomGenre
) {
return function WithPagination() {
const dispatch = useAppDispatch();
const itemKey = genre.id ?? (genre as CustomGenre).apiString;
const mediaState = useAppSelector((state) => state.discover[mediaType]);
const pageState = mediaState ? mediaState[itemKey] : undefined;
const [getVideosByMediaTypeAndGenreId] =
useLazyGetVideosByMediaTypeAndGenreIdQuery();
const [getVideosByMediaTypeAndCustomGenre] =
useLazyGetVideosByMediaTypeAndCustomGenreQuery();
useEffect(() => {
if (!mediaState || !pageState) {
dispatch(initiateItem({ mediaType, itemKey }));
}
}, [mediaState, pageState]);
useEffect(() => {
if (pageState && pageState.page === 0) {
handleNext(pageState.page + 1);
}
}, [pageState]);
const handleNext = useCallback((page: number) => {
if (genre.id) {
getVideosByMediaTypeAndGenreId({
mediaType,
genreId: genre.id,
page,
});
} else {
getVideosByMediaTypeAndCustomGenre({
mediaType,
apiString: (genre as CustomGenre).apiString,
page,
});
}
// dispatch(setNextPage({ mediaType, itemKey }));
}, []);
if (pageState) {
return (
);
}
return ;
};
}
================================================
FILE: src/hooks/redux.ts
================================================
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "../store";
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook = useSelector;
================================================
FILE: src/hooks/useIntersectionObserver.ts
================================================
import { RefObject, useEffect, useState } from "react";
// import { buildThresholdList } from "src/utils";
export default function useIntersectionObserver(
ref: RefObject,
options?: IntersectionObserverInit
) {
const [entry, setEntry] = useState(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
setEntry(entry);
},
options ?? {
root: null,
rootMargin: "0px",
// threshold: buildThresholdList(),
threshold: 1,
}
);
if (ref.current) {
observer.observe(ref.current);
}
return () => {
if (ref.current) {
observer.unobserve(ref.current);
}
};
}, []);
return entry;
}
================================================
FILE: src/hooks/useOffSetTop.ts
================================================
import { useState, useEffect, useCallback } from "react";
export default function useOffSetTop(top: number) {
const [offsetTop, setOffSetTop] = useState(false);
const onScroll = useCallback(() => {
if (window.pageYOffset > top) {
setOffSetTop(true);
} else {
setOffSetTop(false);
}
}, [top]);
useEffect(() => {
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
}, [top]);
return offsetTop;
}
================================================
FILE: src/hooks/useWindowSize.ts
================================================
import { useState, useEffect } from "react";
export default function useWindowSize() {
// Initialize state with undefined width/height so server and client renders match
// Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
const [windowSize, setWindowSize] = useState<{
width: number | undefined;
height: number | undefined;
}>({
width: undefined,
height: undefined,
});
useEffect(() => {
// Handler to call on window resize
function handleResize() {
// Set window width/height to state
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// Add event listener
window.addEventListener("resize", handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
// Remove event listener on cleanup
return () => window.removeEventListener("resize", handleResize);
}, []); // Empty array ensures that effect is only run on mount
return windowSize;
}
================================================
FILE: src/layouts/MainLayout.tsx
================================================
import { Outlet, useLocation, useNavigation } from "react-router-dom";
import Box from "@mui/material/Box";
import DetailModal from "src/components/DetailModal";
import VideoPortalContainer from "src/components/VideoPortalContainer";
import DetailModalProvider from "src/providers/DetailModalProvider";
import PortalProvider from "src/providers/PortalProvider";
import { MAIN_PATH } from "src/constant";
import { Footer, MainHeader } from "src/components/layouts";
import MainLoadingScreen from "src/components/MainLoadingScreen";
export default function MainLayout() {
const location = useLocation();
const navigation = useNavigation();
// console.log("Nav Stat: ", navigation.state);
return (
{navigation.state !== "idle" && }
{/* */}
{location.pathname !== `/${MAIN_PATH.watch}` && }
);
}
================================================
FILE: src/lib/createSafeContext.ts
================================================
import React from "react";
export default function createSafeContext() {
const context = React.createContext(undefined);
function useContext() {
const value = React.useContext(context);
if (value === undefined) {
throw new Error("useContext must be inside a Provider with a value");
}
return value;
}
return [useContext, context.Provider] as const;
}
================================================
FILE: src/main.tsx
================================================
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
import "./CustomClassNameSetup";
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import { RouterProvider } from "react-router-dom";
import { createTheme, ThemeProvider } from "@mui/material/styles";
import store from "./store";
import { extendedApi } from "./store/slices/configuration";
import palette from "./theme/palette";
import router from "./routes";
import MainLoadingScreen from "./components/MainLoadingScreen";
store.dispatch(extendedApi.endpoints.getConfiguration.initiate(undefined));
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
}
/>
);
================================================
FILE: src/pages/GenreExplore.tsx
================================================
import {
LoaderFunctionArgs,
useLoaderData,
// useParams
} from "react-router-dom";
import { COMMON_TITLES } from "src/constant";
import GridPage from "src/components/GridPage";
import { MEDIA_TYPE } from "src/types/Common";
import { CustomGenre, Genre } from "src/types/Genre";
import {
genreSliceEndpoints,
// useGetGenresQuery
} from "src/store/slices/genre";
import store from "src/store";
export async function loader({ params }: LoaderFunctionArgs) {
let genre: CustomGenre | Genre | undefined = COMMON_TITLES.find(
(t) => t.apiString === (params.genreId as string)
);
if (!genre) {
const genres = await store
.dispatch(genreSliceEndpoints.getGenres.initiate(MEDIA_TYPE.Movie))
.unwrap();
genre = genres?.find((t) => t.id.toString() === (params.genreId as string));
}
return genre;
}
export function Component() {
const genre: CustomGenre | Genre | undefined = useLoaderData() as
| CustomGenre
| Genre
| undefined;
// const { genreId } = useParams();
// const { data: genres } = useGetGenresQuery(MEDIA_TYPE.Movie);
// let genre: Genre | CustomGenre | undefined;
// if (isNaN(parseInt(genreId!))) {
// genre = COMMON_TITLES.find((t) => t.apiString === genreId);
// } else {
// genre = genres?.find((t) => t.id.toString() === genreId);
// }
if (genre) {
return ;
}
return null;
}
Component.displayName = "GenreExplore";
================================================
FILE: src/pages/HomePage.tsx
================================================
import Stack from "@mui/material/Stack";
import { COMMON_TITLES } from "src/constant";
import HeroSection from "src/components/HeroSection";
import { genreSliceEndpoints, useGetGenresQuery } from "src/store/slices/genre";
import { MEDIA_TYPE } from "src/types/Common";
import { CustomGenre, Genre } from "src/types/Genre";
import SliderRowForGenre from "src/components/VideoSlider";
import store from "src/store";
export async function loader() {
await store.dispatch(
genreSliceEndpoints.getGenres.initiate(MEDIA_TYPE.Movie)
);
return null;
}
export function Component() {
const { data: genres, isSuccess } = useGetGenresQuery(MEDIA_TYPE.Movie);
if (isSuccess && genres && genres.length > 0) {
return (
{[...COMMON_TITLES, ...genres].map((genre: Genre | CustomGenre) => (
))}
);
}
return null;
}
Component.displayName = "HomePage";
================================================
FILE: src/pages/WatchPage.tsx
================================================
import { useState, useRef, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import Player from "video.js/dist/types/player";
import { Box, Stack, Typography } from "@mui/material";
import { SliderUnstyledOwnProps } from "@mui/base/SliderUnstyled";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PauseIcon from "@mui/icons-material/Pause";
import SkipNextIcon from "@mui/icons-material/SkipNext";
import FullscreenIcon from "@mui/icons-material/Fullscreen";
import SettingsIcon from "@mui/icons-material/Settings";
import BrandingWatermarkOutlinedIcon from "@mui/icons-material/BrandingWatermarkOutlined";
import KeyboardBackspaceIcon from "@mui/icons-material/KeyboardBackspace";
import useWindowSize from "src/hooks/useWindowSize";
import { formatTime } from "src/utils/common";
import MaxLineTypography from "src/components/MaxLineTypography";
import VolumeControllers from "src/components/watch/VolumeControllers";
import VideoJSPlayer from "src/components/watch/VideoJSPlayer";
import PlayerSeekbar from "src/components/watch/PlayerSeekbar";
import PlayerControlButton from "src/components/watch/PlayerControlButton";
import MainLoadingScreen from "src/components/MainLoadingScreen";
export function Component() {
const playerRef = useRef(null);
const [playerState, setPlayerState] = useState({
paused: false,
muted: false,
playedSeconds: 0,
duration: 0,
volume: 0.8,
loaded: 0,
});
const navigate = useNavigate();
const [playerInitialized, setPlayerInitialized] = useState(false);
const windowSize = useWindowSize();
const videoJsOptions = useMemo(() => {
return {
preload: "metadata",
autoplay: true,
controls: false,
// responsive: true,
// fluid: true,
width: windowSize.width,
height: windowSize.height,
sources: [
{
// src: videoData?.video,
// src: "https://d2zihajmogu5jn.cloudfront.net/bipbop-advanced/bipbop_16x9_variant.m3u8",
src: "https://bitmovin-a.akamaihd.net/content/sintel/hls/playlist.m3u8",
type: "application/x-mpegurl",
},
],
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [windowSize]);
const handlePlayerReady = function (player: Player): void {
player.on("pause", () => {
setPlayerState((draft) => {
return { ...draft, paused: true };
});
});
player.on("play", () => {
setPlayerState((draft) => {
return { ...draft, paused: false };
});
});
player.on("timeupdate", () => {
setPlayerState((draft) => {
return { ...draft, playedSeconds: player.currentTime() };
});
});
player.one("durationchange", () => {
setPlayerInitialized(true);
setPlayerState((draft) => ({ ...draft, duration: player.duration() }));
});
playerRef.current = player;
setPlayerState((draft) => {
return { ...draft, paused: player.paused() };
});
};
const handleVolumeChange: SliderUnstyledOwnProps["onChange"] = (_, value) => {
playerRef.current?.volume((value as number) / 100);
setPlayerState((draft) => {
return { ...draft, volume: (value as number) / 100 };
});
};
const handleSeekTo = (v: number) => {
playerRef.current?.currentTime(v);
};
const handleGoBack = () => {
navigate("/browse");
};
if (!!videoJsOptions.width) {
return (
{playerRef.current && playerInitialized && (
Title
12+
{/* Seekbar */}
{/* end Seekbar */}
{/* Controller */}
{/* left controller */}
{!playerState.paused ? (
{
playerRef.current?.pause();
}}
>
) : (
{
playerRef.current?.play();
}}
>
)}
{
playerRef.current?.muted(!playerState.muted);
setPlayerState((draft) => {
return { ...draft, muted: !draft.muted };
});
}}
value={playerState.volume}
handleVolume={handleVolumeChange}
/>
{`${formatTime(playerState.playedSeconds)} / ${formatTime(
playerState.duration
)}`}
{/* end left controller */}
{/* middle time */}
Description
{/* end middle time */}
{/* right controller */}
{/* end right controller */}
{/* end Controller */}
)}
);
}
return null;
}
Component.displayName = "WatchPage";
================================================
FILE: src/providers/DetailModalProvider.tsx
================================================
import { ReactNode, useEffect, useState, useCallback } from "react";
import { useLocation } from "react-router-dom";
import { INITIAL_DETAIL_STATE } from "src/constant";
import createSafeContext from "src/lib/createSafeContext";
import { useLazyGetAppendedVideosQuery } from "src/store/slices/discover";
import { MEDIA_TYPE } from "src/types/Common";
import { MovieDetail } from "src/types/Movie";
interface DetailType {
id?: number;
mediaType?: MEDIA_TYPE;
}
export interface DetailModalConsumerProps {
detail: { mediaDetail?: MovieDetail } & DetailType;
setDetailType: (newDetailType: DetailType) => void;
}
export const [useDetailModal, Provider] =
createSafeContext();
export default function DetailModalProvider({
children,
}: {
children: ReactNode;
}) {
const location = useLocation();
const [detail, setDetail] = useState<
{ mediaDetail?: MovieDetail } & DetailType
>(INITIAL_DETAIL_STATE);
const [getAppendedVideos] = useLazyGetAppendedVideosQuery();
const handleChangeDetail = useCallback(
async (newDetailType: { mediaType?: MEDIA_TYPE; id?: number }) => {
if (!!newDetailType.id && newDetailType.mediaType) {
const response = await getAppendedVideos({
mediaType: newDetailType.mediaType,
id: newDetailType.id as number,
}).unwrap();
setDetail({ ...newDetailType, mediaDetail: response });
} else {
setDetail(INITIAL_DETAIL_STATE);
}
},
[]
);
useEffect(() => {
setDetail(INITIAL_DETAIL_STATE);
}, [location.pathname, setDetail]);
return (
{children}
);
}
================================================
FILE: src/providers/PortalProvider.tsx
================================================
import { ReactNode, useState, useCallback } from "react";
import { Movie } from "src/types/Movie";
import createSafeContext from "src/lib/createSafeContext";
export interface PortalConsumerProps {
setPortal: (anchor: HTMLElement | null, vidoe: Movie | null) => void;
}
export interface PortalDataConsumerProps {
anchorElement: HTMLElement | null;
miniModalMediaData: Movie | null;
}
export const [usePortal, Provider] =
createSafeContext();
export const [usePortalData, PortalDataProvider] =
createSafeContext();
export default function PortalProvider({ children }: { children: ReactNode }) {
const [anchorElement, setAnchorElement] = useState(null);
const [miniModalMediaData, setMiniModalMediaData] = useState(
null
);
const handleChangePortal = useCallback(
(anchor: HTMLElement | null, video: Movie | null) => {
setAnchorElement(anchor);
setMiniModalMediaData(video);
},
[]
);
return (
{children}
);
}
================================================
FILE: src/routes/index.tsx
================================================
import { Navigate, createBrowserRouter } from "react-router-dom";
import { MAIN_PATH } from "src/constant";
import MainLayout from "src/layouts/MainLayout";
const router = createBrowserRouter([
{
path: "/",
element: ,
children: [
{
path: MAIN_PATH.root,
element: ,
},
{
path: MAIN_PATH.browse,
lazy: () => import("src/pages/HomePage"),
},
{
path: MAIN_PATH.genreExplore,
children: [
{
path: ":genreId",
lazy: () => import("src/pages/GenreExplore"),
},
],
},
{
path: MAIN_PATH.watch,
lazy: () => import("src/pages/WatchPage"),
},
],
},
]);
export default router;
================================================
FILE: src/store/index.ts
================================================
import { configureStore } from "@reduxjs/toolkit";
import { tmdbApi } from "./slices/apiSlice";
import discoverReducer from "./slices/discover";
const store = configureStore({
reducer: {
discover: discoverReducer,
[tmdbApi.reducerPath]: tmdbApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(tmdbApi.middleware),
});
export type RootState = ReturnType;
export type AppDispatch = typeof store.dispatch;
export default store;
================================================
FILE: src/store/slices/apiSlice.ts
================================================
import { API_ENDPOINT_URL } from "src/constant";
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const tmdbApi = createApi({
reducerPath: "tmdbApi",
baseQuery: fetchBaseQuery({ baseUrl: API_ENDPOINT_URL }),
endpoints: (build) => ({}),
});
================================================
FILE: src/store/slices/configuration.ts
================================================
import { TMDB_V3_API_KEY } from "src/constant";
import { tmdbApi } from "./apiSlice";
type ConfigurationType = {
images: {
base_url: string;
secure_base_url: string;
backdrop_sizes: string[];
logo_sizes: string[];
poster_sizes: string[];
profile_sizes: string[];
still_sizes: string[];
};
change_keys: string[];
};
export const extendedApi = tmdbApi.injectEndpoints({
endpoints: (build) => ({
getConfiguration: build.query({
query: () => ({
url: "/configuration",
params: { api_key: TMDB_V3_API_KEY },
}),
}),
}),
});
export const { useGetConfigurationQuery } = extendedApi;
================================================
FILE: src/store/slices/discover.ts
================================================
import { TMDB_V3_API_KEY } from "src/constant";
import { tmdbApi } from "./apiSlice";
import { MEDIA_TYPE, PaginatedMovieResult } from "src/types/Common";
import { MovieDetail } from "src/types/Movie";
import { createSlice, isAnyOf } from "@reduxjs/toolkit";
const initialState: Record> = {};
export const initialItemState: PaginatedMovieResult = {
page: 0,
results: [],
total_pages: 0,
total_results: 0,
};
const discoverSlice = createSlice({
name: "discover",
initialState,
reducers: {
setNextPage: (state, action) => {
const { mediaType, itemKey } = action.payload;
state[mediaType][itemKey].page += 1;
},
initiateItem: (state, action) => {
const { mediaType, itemKey } = action.payload;
if (!state[mediaType]) {
state[mediaType] = {};
}
if (!state[mediaType][itemKey]) {
state[mediaType][itemKey] = initialItemState;
}
},
},
extraReducers(builder) {
builder.addMatcher(
isAnyOf(
extendedApi.endpoints.getVideosByMediaTypeAndCustomGenre.matchFulfilled,
extendedApi.endpoints.getVideosByMediaTypeAndGenreId.matchFulfilled
),
(state, action) => {
const {
page,
results,
total_pages,
total_results,
mediaType,
itemKey,
} = action.payload;
state[mediaType][itemKey].page = page;
state[mediaType][itemKey].results.push(...results);
state[mediaType][itemKey].total_pages = total_pages;
state[mediaType][itemKey].total_results = total_results;
}
);
},
});
export const { setNextPage, initiateItem } = discoverSlice.actions;
export default discoverSlice.reducer;
const extendedApi = tmdbApi.injectEndpoints({
endpoints: (build) => ({
getVideosByMediaTypeAndGenreId: build.query<
PaginatedMovieResult & {
mediaType: MEDIA_TYPE;
itemKey: number | string;
},
{ mediaType: MEDIA_TYPE; genreId: number; page: number }
>({
query: ({ mediaType, genreId, page }) => ({
url: `/discover/${mediaType}`,
params: { api_key: TMDB_V3_API_KEY, with_genres: genreId, page },
}),
transformResponse: (
response: PaginatedMovieResult,
_,
{ mediaType, genreId }
) => ({
...response,
mediaType,
itemKey: genreId,
}),
}),
getVideosByMediaTypeAndCustomGenre: build.query<
PaginatedMovieResult & {
mediaType: MEDIA_TYPE;
itemKey: number | string;
},
{ mediaType: MEDIA_TYPE; apiString: string; page: number }
>({
query: ({ mediaType, apiString, page }) => ({
url: `/${mediaType}/${apiString}`,
params: { api_key: TMDB_V3_API_KEY, page },
}),
transformResponse: (
response: PaginatedMovieResult,
_,
{ mediaType, apiString }
) => {
return {
...response,
mediaType,
itemKey: apiString,
};
},
}),
getAppendedVideos: build.query<
MovieDetail,
{ mediaType: MEDIA_TYPE; id: number }
>({
query: ({ mediaType, id }) => ({
url: `/${mediaType}/${id}`,
params: { api_key: TMDB_V3_API_KEY, append_to_response: "videos" },
}),
}),
getSimilarVideos: build.query<
PaginatedMovieResult,
{ mediaType: MEDIA_TYPE; id: number }
>({
query: ({ mediaType, id }) => ({
url: `/${mediaType}/${id}/similar`,
params: { api_key: TMDB_V3_API_KEY },
}),
}),
}),
});
export const {
useGetVideosByMediaTypeAndGenreIdQuery,
useLazyGetVideosByMediaTypeAndGenreIdQuery,
useGetVideosByMediaTypeAndCustomGenreQuery,
useLazyGetVideosByMediaTypeAndCustomGenreQuery,
useGetAppendedVideosQuery,
useLazyGetAppendedVideosQuery,
useGetSimilarVideosQuery,
useLazyGetSimilarVideosQuery,
} = extendedApi;
================================================
FILE: src/store/slices/genre.ts
================================================
import { TMDB_V3_API_KEY } from "src/constant";
import { Genre } from "src/types/Genre";
import { tmdbApi } from "./apiSlice";
const extendedApi = tmdbApi.injectEndpoints({
endpoints: (build) => ({
getGenres: build.query({
query: (mediaType) => ({
url: `/genre/${mediaType}/list`,
params: { api_key: TMDB_V3_API_KEY },
}),
transformResponse: (response: { genres: Genre[] }) => {
return response.genres;
},
}),
}),
});
export const { useGetGenresQuery, endpoints: genreSliceEndpoints } = extendedApi;
================================================
FILE: src/theme/palette.ts
================================================
import type { PaletteMode } from "@mui/material";
const PRIMARY = {
light: "#B8B8B8",
main: "#141414",
dark: "#0E0A0A",
};
const GREY = {
100: "#F9FAFB",
200: "#F4F6F8",
300: "#DFE3E8",
400: "#C4CDD5",
500: "#919EAB",
600: "#637381",
700: "#454F5B",
800: "#212B36",
900: "#161C24",
};
const COMMON = {
common: { black: "#000", white: "#fff" },
primary: { ...PRIMARY, contrastText: "#fff" },
grey: GREY,
action: {
active: GREY[500],
hoverOpacity: 0.08,
disabledOpacity: 0.48,
},
};
const palette = {
...COMMON,
text: { primary: "#fff", secondary: GREY[500], disabled: GREY[600] },
background: { default: PRIMARY.main, paper: PRIMARY.main },
mode: "dark" as PaletteMode,
};
export default palette;
================================================
FILE: src/types/Common.ts
================================================
import { Movie } from "src/types/Movie";
export enum MEDIA_TYPE {
Movie = "movie",
Tv = "tv",
}
export type Company = {
description: string;
headquarters: string;
homepage: string;
id: number;
logo_path: string;
name: string;
origin_country: string;
parent_company: null | object;
};
export type Country = {
iso_3166_1: string;
english_name: string;
};
export type Language = {
iso_639_1: string;
english_name: string;
name: string;
};
export type PaginatedResult = {
page: number;
total_pages: number;
total_results: number;
};
export type PaginatedMovieResult = PaginatedResult & { results: Movie[] };
================================================
FILE: src/types/Genre.ts
================================================
export type Genre = {
id: number;
name: string;
};
export type CustomGenre = {
id?: number;
name: string;
apiString: string;
};
================================================
FILE: src/types/Movie.ts
================================================
import { Company, Country, Language } from './Common';
import { Genre } from './Genre';
export type Appended_Video = {
id: string;
iso_639_1: string;
iso_3166_1: string;
key: string;
name: string;
official: boolean;
published_at: string;
site: string;
size: number;
type: string;
};
export type MovieDetail = {
adult: boolean;
backdrop_path: string | null;
belongs_to_collection: null;
budget: number;
genres: Genre[];
homepage: string;
id: number;
imdb_id: string;
original_language: string;
original_title: string;
overview: string;
popularity: number;
poster_path: string | null;
production_companies: Company[];
production_countries: Country[];
release_date: string;
revenue: number;
runtime: number;
spoken_languages: Language[];
status: string;
tagline: string;
title: string;
video: boolean;
videos: { results: Appended_Video[] };
vote_average: number;
vote_count: number;
};
export type Movie = {
poster_path: string | null;
adult: boolean;
overview: string;
release_date: string;
genre_ids: number[];
id: number;
original_title: string;
original_language: string;
title: string;
backdrop_path: string | null;
popularity: number;
vote_count: number;
video: boolean;
vote_average: number;
};
================================================
FILE: src/utils/common.ts
================================================
export const getRandomNumber = (maxNumber: number) =>
Math.floor(Math.random() * maxNumber);
export const formatMinuteToReadable = (minutes: number) => {
const h = Math.floor(minutes / 60);
const m = minutes - h * 60;
if (h > 0) {
return `${h}h ${m}m`;
} else {
return `${m}m`;
}
};
export const formatBytes = (bytes: number, decimals: number = 2) => {
if (!+bytes) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = [
"Bytes",
"KiB",
"MiB",
"GiB",
"TiB",
"PiB",
"EiB",
"ZiB",
"YiB",
];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
};
export const formatTime = (current: number) => {
const h = Math.floor(current / 3600);
const m = Math.floor((current - h * 3600) / 60);
const s = Math.floor(current % 60);
const sString = s < 10 ? "0" + s.toString() : s.toString();
const mString = m < 10 ? "0" + m.toString() : m.toString();
if (h > 0) {
const hString = h < 10 ? "0" + h.toString() : h.toString();
return `${hString}:${mString}:${sString}`;
} else {
return `${mString}:${sString}`;
}
};
================================================
FILE: src/utils/index.ts
================================================
export { formatMinuteToReadable, getRandomNumber } from "./common";
function buildThresholdList() {
let thresholds = [];
const numSteps = 20;
for (let i = 1.0; i <= numSteps; i++) {
let ratio = i / numSteps;
thresholds.push(ratio);
}
thresholds.push(0);
return thresholds;
}
export { buildThresholdList };
================================================
FILE: src/videojs-youtube.d.ts
================================================
declare module "videojs-youtube" {}
================================================
FILE: src/vite-env.d.ts
================================================
///
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "."
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
================================================
FILE: tsconfig.node.json
================================================
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
================================================
FILE: vercel.json
================================================
{
"rewrites": [{ "source": "/(.*)", "destination": "/" }]
}
================================================
FILE: vite.config.ts
================================================
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tsconfigPaths()]
})