diff --git a/__mocks__/zustand.ts b/__mocks__/zustand.ts new file mode 100644 index 0000000..8a3c1ff --- /dev/null +++ b/__mocks__/zustand.ts @@ -0,0 +1,56 @@ +// __mocks__/zustand.ts +import * as zustand from "zustand"; +import { act } from "@testing-library/react"; + +const { create: actualCreate, createStore: actualCreateStore } = + jest.requireActual("zustand"); + +// a variable to hold reset functions for all stores declared in the app +export const storeResetFns = new Set<() => void>(); + +const createUncurried = (stateCreator: zustand.StateCreator) => { + const store = actualCreate(stateCreator); + const initialState = store.getInitialState(); + storeResetFns.add(() => { + store.setState(initialState, true); + }); + return store; +}; + +// when creating a store, we get its initial state, create a reset function and add it in the set +export const create = ((stateCreator: zustand.StateCreator) => { + console.log("zustand create mock"); + + // to support curried version of create + return typeof stateCreator === "function" + ? createUncurried(stateCreator) + : createUncurried; +}) as typeof zustand.create; + +const createStoreUncurried = (stateCreator: zustand.StateCreator) => { + const store = actualCreateStore(stateCreator); + const initialState = store.getInitialState(); + storeResetFns.add(() => { + store.setState(initialState, true); + }); + return store; +}; + +// when creating a store, we get its initial state, create a reset function and add it in the set +export const createStore = ((stateCreator: zustand.StateCreator) => { + console.log("zustand createStore mock"); + + // to support curried version of createStore + return typeof stateCreator === "function" + ? createStoreUncurried(stateCreator) + : createStoreUncurried; +}) as typeof zustand.createStore; + +// reset all stores after each test run +afterEach(() => { + act(() => { + storeResetFns.forEach((resetFn) => { + resetFn(); + }); + }); +}); diff --git a/app/anime/detail/[id]/page.tsx b/app/anime/detail/[id]/page.tsx index 9e0b119..34c98da 100644 --- a/app/anime/detail/[id]/page.tsx +++ b/app/anime/detail/[id]/page.tsx @@ -1,19 +1,25 @@ -import { Suspense } from 'react'; +import { Suspense } from "react"; -import { Banner } from '@/components/detail/banner/Banner'; -import CardBanner from '@/components/detail/cardbanner/CardBanner'; -import EpisodesContainer from '@/components/detail/episodes/EpisodesContainer'; -import RelationComponent from '@/components/detail/relation/RelationComponent'; -import InfoDetails from '@/components/detail/infodetails/InfoDetails'; -import Trailer from '@/components/media/Trailer'; -import SkeletonEpisodes from '@/components/skeleton/SkeletonEpisodes'; +import Banner from "@/components/detail/banner/Banner"; +import CardBanner from "@/components/detail/cardbanner/CardBanner"; +import AnimeContainerCard from "@/components/card/animecard/AnimeContainerCard"; +import EpisodesContainer from "@/components/detail/episodes/EpisodesContainer"; +import RelationComponent from "@/components/detail/relation/RelationComponent"; +import InfoDetails from "@/components/detail/infodetails/InfoDetails"; +import Trailer from "@/components/media/Trailer"; +import SkeletonEpisodes from "@/components/skeleton/SkeletonEpisodes"; -import { AnimeServiceV2 } from '@/services'; -import { AnimeInfo } from '@/types/anime.type'; +import { AnimeServiceV2 } from "@/services"; +import { AnimeInfo, RelationOrRecommendation } from "@/types/anime.type"; async function DetailPage({ params }: { params: { id: string } }) { const { id } = params; - const { data: dataInfo }: { data: AnimeInfo } = await AnimeServiceV2.getAnimeInfoV2(id); + const { data: dataInfo }: { data: AnimeInfo } = + await AnimeServiceV2.getAnimeInfoV2(id); + const { + data: { results: dataRecommendations }, + }: { data: { results: RelationOrRecommendation[] } } = + await AnimeServiceV2.getRecommendationAnime(id); return ( <> @@ -23,18 +29,25 @@ async function DetailPage({ params }: { params: { id: string } }) { - }> - { - (dataInfo.id_provider?.idGogo || dataInfo.id_provider?.idGogoDub) && + }> + {(dataInfo.id_provider?.idGogo || + dataInfo.id_provider?.idGogoDub) && ( - } + )} - +
+ + {dataRecommendations.length > 0 && ( + + )} +
- ) + ); } export default DetailPage; diff --git a/app/anime/page.tsx b/app/anime/page.tsx index 6beb708..25ec77c 100644 --- a/app/anime/page.tsx +++ b/app/anime/page.tsx @@ -1,10 +1,10 @@ import HeroMediaCarousel from "@/components/hero/HeroMediaCarousel"; -import { AnimeContainerCard } from "../components/card/animecard/AnimeContainerCard"; +import AnimeContainerCard from "../components/card/animecard/AnimeContainerCard"; import { AnimeServiceV1, AnimeServiceV2 } from "../services"; export default async function Home() { const { data: dataTrending } = await AnimeServiceV2.getTrendingAnime(); - const { data: dataRecent } = await AnimeServiceV1.getRecentEpisode(); + const { data: dataRecent } = await AnimeServiceV1.getRecentEpisodeGogo(); const { data: dataPopular } = await AnimeServiceV2.getPopularAnime(); return ( @@ -12,14 +12,18 @@ export default async function Home() {
- +
- +
); } - - diff --git a/app/anime/watch/[id]/page.tsx b/app/anime/watch/[id]/page.tsx deleted file mode 100644 index 3d2c1af..0000000 --- a/app/anime/watch/[id]/page.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import EpisodesComponent from '@/components/watch/episodes/EpisodesComponent'; -import { Media } from '@/components/media/Media'; -import { AnimeServiceV1 } from '@/services'; -import { AnimeDetails, StreamInfo } from '@/types/anime.type'; - -type WatchPageParams = { - params: { id: string }; - searchParams: { title: string; ep: number }; -} - -async function WatchPage({ params, searchParams }: WatchPageParams) { - const { id } = params; - const { title, ep } = searchParams; - - // Fetch the stream info (for video sources) - const { data: dataStream }: { data: StreamInfo } = await AnimeServiceV1.getAnimeStream(`${id}`); - // Fetch the anime details (for title, image, etc.) - const { data: dataInfo }: { data: AnimeDetails } = await AnimeServiceV1.getAnimeInfoV1(`${title}`); - - return ( - <> -
- - -
- - - ); -} - -export default WatchPage; - diff --git a/app/anime/watch/loading.tsx b/app/anime/watch/loading.tsx index 83f3a4a..f474285 100644 --- a/app/anime/watch/loading.tsx +++ b/app/anime/watch/loading.tsx @@ -1,16 +1,15 @@ -import SkeletonEpisodeNum from '@/components/skeleton/SkeletonEpisodeNum'; -import SkeletonMediaPlayer from '@/components/skeleton/SkeletonMediaPlayer'; +import SkeletonEpisodeNum from "@/components/skeleton/SkeletonEpisodeNum"; +import SkeletonMediaPlayer from "@/components/skeleton/SkeletonMediaPlayer"; -function loading() { +function Loading() { return ( <> -
+
- ) + ); } -export default loading - +export default Loading; diff --git a/app/anime/watch/page.tsx b/app/anime/watch/page.tsx new file mode 100644 index 0000000..6afbc52 --- /dev/null +++ b/app/anime/watch/page.tsx @@ -0,0 +1,94 @@ +// WatchPage.tsx +"use client"; +import useSWR from "swr"; +import EpisodesComponent from "@/components/watch/episodes/EpisodesComponent"; +import Media from "@/components/media/Media"; +import Error from "@/error"; +import Loading from "./loading"; +import { AnimeDetails, AnimeInfo } from "@/types/anime.type"; +import { useState, useEffect, useCallback } from "react"; + +type WatchPageParams = { + searchParams: { id: string; ep: string; isDub?: string }; +}; + +const doesIdNumber = (id: unknown): boolean => Number.isInteger(Number(id)); + +// Fetch Anime Info based on ID +const fetchAnimeInfoV1 = async (idProvider: string) => { + if (!idProvider) return null; + const res = await fetch(`/api/anime-infov1?query=${idProvider}`); + return res.json(); +}; + +const fetchAnimeInfoV2 = async (id: string) => { + if (doesIdNumber(id)) { + const res = await fetch(`/api/anime-infov2?query=${id}`); + return res.json(); + } +}; + +function WatchPage({ searchParams }: WatchPageParams) { + const { id, ep, isDub } = searchParams; + + const [episodeId, setEpisodeId] = useState(""); + const [isLoading, setIsLoading] = useState(true); + + // Fetch data from API v2 and API v1 separately + const { data: animeInfoV2, error: errorInfoV2 } = useSWR( + doesIdNumber(id) ? id : null, + fetchAnimeInfoV2 + ); + + const idProvider = isDub + ? animeInfoV2?.id_provider.idGogoDub + : animeInfoV2?.id_provider.idGogo || id; + + const { data: animeInfoV1, error: errorInfoV1 } = useSWR( + idProvider ? [idProvider, "v1"] : null, + () => fetchAnimeInfoV1(idProvider!) + ); + + // Set the initial episode ID once the anime info and episodes are loaded + useEffect(() => { + if (animeInfoV1?.episodes && ep) { + const selectedEpisode = animeInfoV1?.episodes.find( + (e) => Number(e.number) === Number(ep) + ); + if (selectedEpisode) { + setEpisodeId(selectedEpisode.id); + setIsLoading(false); + } + } + }, [animeInfoV1, ep]); + + // Handle episode change through the EpisodesComponent + const handleEpisodeChange = useCallback((newEpisodeId: string) => { + setEpisodeId(newEpisodeId); + }, []); + + // Handle loading and error states + if (errorInfoV2 || errorInfoV1) return ; + if (!animeInfoV2 && doesIdNumber(id)) return ; + if (isLoading || !animeInfoV1) return ; + + return ( +
+ + +
+ ); +} + +export default WatchPage; diff --git a/app/api/anime-infov1/route.ts b/app/api/anime-infov1/route.ts index 928b63e..a7ededd 100644 --- a/app/api/anime-infov1/route.ts +++ b/app/api/anime-infov1/route.ts @@ -1,20 +1,25 @@ -import { NextRequest, NextResponse } from 'next/server' -import { AnimeServiceV1 } from '@/services' +import { NextRequest, NextResponse } from "next/server"; +import { AnimeServiceV1 } from "@/services"; export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams - const query = searchParams.get('query') + const searchParams = request.nextUrl.searchParams; + const query = searchParams.get("query"); if (!query) { - return NextResponse.json({ error: 'Query parameter is required' }, { status: 400 }) + return NextResponse.json( + { error: "Query parameter is required" }, + { status: 400 } + ); } try { - const { data } = await AnimeServiceV1.getAnimeInfoV1(query) - return NextResponse.json(data) + const { data } = await AnimeServiceV1.getAnimeInfoV1Gogo(query); + return NextResponse.json(data); } catch (error) { - console.error('Error searching anime:', error) - return NextResponse.json({ error: 'Failed to search anime' }, { status: 500 }) + console.error("Error searching anime:", error); + return NextResponse.json( + { error: "Failed to search anime" }, + { status: 500 } + ); } } - diff --git a/app/api/anime-infov2/route.ts b/app/api/anime-infov2/route.ts new file mode 100644 index 0000000..f1094e5 --- /dev/null +++ b/app/api/anime-infov2/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { AnimeServiceV2 } from "@/services"; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const query = searchParams.get("query"); + + if (!query) { + return NextResponse.json( + { error: "Query parameter is required" }, + { status: 400 } + ); + } + + try { + const { data } = await AnimeServiceV2.getAnimeInfoV2(query); + return NextResponse.json(data); + } catch (error) { + console.error("Error searching anime:", error); + return NextResponse.json( + { error: "Failed to search anime" }, + { status: 500 } + ); + } +} diff --git a/app/api/animestream/route.ts b/app/api/animestream/route.ts new file mode 100644 index 0000000..e8f4e1b --- /dev/null +++ b/app/api/animestream/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { AnimeServiceV1 } from "@/services"; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const query = searchParams.get("query"); + + if (!query) { + return NextResponse.json( + { error: "Query parameter is required" }, + { status: 400 } + ); + } + + try { + const { data } = await AnimeServiceV1.getAnimeStreamGogo(query); + return NextResponse.json(data); + } catch (error) { + console.error("Error searching anime:", error); + return NextResponse.json( + { error: "Failed to search anime" }, + { status: 500 } + ); + } +} + diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index 665b5c7..4783e5e 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -1,22 +1,27 @@ "use client"; -import { useThemeStore } from "@/store/themeStore" +import { useThemeStore } from "@/store/themeStore"; function Footer() { const { theme } = useThemeStore(); return ( <> -
+

- Disclaimer | Please use this site wisely, dont use it to search some inappropriate film, because the purpose of this project is not for that, grow up. -

-

- Copyright © 2024 Watchlo + Disclaimer | Please use this site wisely, dont use it to search some + inappropriate film, because the purpose of this project is not for + that, grow up.

+

Copyright © 2024 Watchlo | Made by faruqi.

- ) + ); } -export default Footer +export default Footer; diff --git a/app/components/card/animecard/AnimeCard.tsx b/app/components/card/animecard/AnimeCard.tsx index 72b52f7..52ef1f8 100644 --- a/app/components/card/animecard/AnimeCard.tsx +++ b/app/components/card/animecard/AnimeCard.tsx @@ -1,58 +1,71 @@ -import { AnimePopular, AnimeTrending, AnimeType } from "@/types/anime.type"; +import { Anime, AnimeRecent, AnimeType } from "@/types/anime.type"; import Link from "next/link"; import CardImage from "../chunk/CardImage"; import CardTitle from "../chunk/CardTitle"; import { Routes } from "@/types/global"; +import SmallInfo from "@/components/detail/infodetails/SmallInfo"; interface AnimeCardProps { anime: AnimeType; } -export const AnimeCard = ({ anime }: AnimeCardProps) => { +const AnimeCard = ({ anime }: AnimeCardProps) => { // Type narrowing for Popular anime - const isPopularAnime = (anime: AnimeType): anime is AnimePopular => 'coverImage' in anime; + const isPopularAnime = (anime: AnimeType): anime is Anime => + "coverImage" in anime; - // Type narrowing for Trending anime - const isTrendingAnime = (anime: AnimeType): anime is AnimeTrending => 'episodeNumber' in anime; + // Type narrowing for Recently Updated Anime + const isRecentAnime = (anime: AnimeType): anime is AnimeRecent => + "episodeNumber" in anime; // Extract the title based on the type of anime const animeTitle = isPopularAnime(anime) - ? anime.title.userPreferred || anime.title.romaji || anime.title.english || anime.title.native + ? anime.title?.userPreferred || + anime.title?.romaji || + anime.title?.english || + anime.title?.native : anime.title; // Extract the image URL based on whether it's popular or trending const animeImage = isPopularAnime(anime) - ? anime.coverImage.large // Use the large image for popular anime - : anime.image; // For trending or other types, use the image field + ? anime.coverImage?.large // Use the large image for popular anime + : anime.image; const determineRoutes = (anime: AnimeType): Routes | string => { - // condition where the anime is trending anime + // condition where the anime is trending anime if ("episodeNumber" in anime) { - return ({ pathname: `/anime/watch/${anime.episodeId}`, query: { title: anime.id, ep: anime.episodeNumber } }) + return { + pathname: `/anime/watch`, + query: { id: String(anime.id), ep: anime.episodeNumber }, + }; } - // condition where the anime is popular anime - return `/anime/detail/${anime.id}` - } - - const formattedStatus = isPopularAnime(anime) && anime.status?.replace(/_/g, " "); + // condition where the anime is popular anime + return `/anime/detail/${anime.id}`; + }; return ( <> - -
+
- {isTrendingAnime(anime) && ( + {isRecentAnime(anime) && (

Episode {anime.episodeNumber}

)} {isPopularAnime(anime) && ( -

Status: {formattedStatus}

+ )}
@@ -60,3 +73,4 @@ export const AnimeCard = ({ anime }: AnimeCardProps) => { ); }; +export default AnimeCard; diff --git a/app/components/card/animecard/AnimeContainerCard.tsx b/app/components/card/animecard/AnimeContainerCard.tsx index 547ed7c..e5c35e3 100644 --- a/app/components/card/animecard/AnimeContainerCard.tsx +++ b/app/components/card/animecard/AnimeContainerCard.tsx @@ -1,30 +1,37 @@ "use client"; -import { useThemeStore } from '@/store/themeStore' -import { AnimeCard } from './AnimeCard' -import { AnimeType, Relation } from '@/types/anime.type' +import { useThemeStore } from "@/store/themeStore"; +import AnimeCard from "./AnimeCard"; +import { AnimeType, RelationOrRecommendation } from "@/types/anime.type"; interface AnimeContainerProps { - animes: AnimeType[] | Relation[]; - containerTitle: string + animes: AnimeType[] | RelationOrRecommendation[]; + containerTitle: string; } -export const AnimeContainerCard = ({ animes, containerTitle }: AnimeContainerProps) => { +const AnimeContainerCard = ({ + animes, + containerTitle, +}: AnimeContainerProps) => { const { theme } = useThemeStore(); return (
-

+

{containerTitle}

- {animes.map((item) => ( - - ))} + {animes?.map( + (item) => + item && + )}
- ) -} + ); +}; + +export default AnimeContainerCard; diff --git a/app/components/card/chunk/CardImage.tsx b/app/components/card/chunk/CardImage.tsx index 4ae655f..87ed878 100644 --- a/app/components/card/chunk/CardImage.tsx +++ b/app/components/card/chunk/CardImage.tsx @@ -1,9 +1,15 @@ -'use client' +"use client"; -import { useState } from 'react' -import Image from 'next/image' +import { useState } from "react"; +import Image from "next/image"; -export default function CardImage({ image, alt }: { image: string, alt: string }) { +export default function CardImage({ + image, + alt, +}: { + image?: string; + alt: string; +}) { const [isImageLoading, setImageLoading] = useState(true); return ( @@ -15,7 +21,7 @@ export default function CardImage({ image, alt }: { image: string, alt: string } className={` max-h-44 min-h-44 object-cover transition-custom-blur - ${isImageLoading ? 'scale-110 blur-2xl' : 'scale-100 blur-0'} + ${isImageLoading ? "scale-110 blur-2xl" : "scale-100 blur-0"} hover:scale-110 hover:duration-300 sm:min-h-72 sm:min-w-52 `} @@ -26,5 +32,5 @@ export default function CardImage({ image, alt }: { image: string, alt: string } />
- ) + ); } diff --git a/app/components/card/moviescard/MoviesCard.tsx b/app/components/card/moviescard/MoviesCard.tsx index 93e110d..68b1937 100644 --- a/app/components/card/moviescard/MoviesCard.tsx +++ b/app/components/card/moviescard/MoviesCard.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import CardImage from "../chunk/CardImage"; import CardTitle from "../chunk/CardTitle"; import { MovieInfo, TVInfo } from "@/types/movies.type"; -import { formatDate } from "@/utils/formatted"; +import SmallInfo from "@/components/detail/infodetails/SmallInfo"; interface MoviesCardProps { movie: MovieInfo | TVInfo; @@ -12,53 +12,72 @@ interface MoviesCardProps { } interface Routes { - pathname: string, + pathname: string; query: { id: string | number; season?: number; ep?: number; - } + }; } -export const MoviesCard = ({ movie, isDetail, season, ep }: MoviesCardProps) => { - const isSeries = (movie: TVInfo): movie is TVInfo => 'first_air_date' in movie; +export const MoviesCard = ({ + movie, + isDetail, + season, + ep, +}: MoviesCardProps) => { + const isSeries = (movie: TVInfo): movie is TVInfo => + "first_air_date" in movie; const determineRoutes = (movie: MovieInfo | TVInfo): Routes | string => { if (!isDetail) { if (isSeries(movie as TVInfo)) - return ({ pathname: `/series/watch/${movie.id}`, query: { id: movie.id, season, ep } }) - return ({ pathname: `/movie/watch/${movie.id}`, query: { id: movie.id } }) + return { + pathname: `/series/watch/${movie.id}`, + query: { id: movie.id, season, ep }, + }; + return { pathname: `/movie/watch/${movie.id}`, query: { id: movie.id } }; } - if (isSeries(movie as TVInfo)) - return `/series/detail/${movie.id}` - return `/movie/detail/${movie.id}` - } + if (isSeries(movie as TVInfo)) return `/series/detail/${movie.id}`; + return `/movie/detail/${movie.id}`; + }; const determineTitle = (movie: MovieInfo | TVInfo): string => { if ("first_air_date" in movie) { return movie.name; } return movie.title; - } + }; return ( <> - -
+
- - {"first_air_date" in movie ? -

First Air: {formatDate(movie.first_air_date)}

: -

Release Date: {formatDate(movie.release_date)}

- } +
+ {"first_air_date" in movie ? ( + + ) : ( + + )} +
); }; - diff --git a/app/components/detail/banner/Banner.tsx b/app/components/detail/banner/Banner.tsx index 35e34c6..b1364bd 100644 --- a/app/components/detail/banner/Banner.tsx +++ b/app/components/detail/banner/Banner.tsx @@ -5,10 +5,14 @@ import { AnimeInfo } from "@/types/anime.type"; import { MovieInfo, TVInfo } from "@/types/movies.type"; interface BannerProps { - item: AnimeInfo | MovieInfo | TVInfo; + item: + | AnimeInfo + | Omit + | Omit; } -export const Banner = ({ item }: BannerProps) => { +const Banner = ({ item }: BannerProps) => { + const fallBackBanner = "/fallback-banner.webp"; const [isImageLoading, setImageLoading] = useState(true); // Function to determine the title @@ -16,7 +20,13 @@ export const Banner = ({ item }: BannerProps) => { if ("title" in item) { // For anime or movie if (typeof item.title === "object") { - return item.title.userPreferred || item.title.english || item.title.romaji || item.title.native || "Unknown Title"; + return ( + item.title.userPreferred || + item.title.english || + item.title.romaji || + item.title.native || + "Unknown Title" + ); } return item.title || "Unknown Title"; } else if ("name" in item) { @@ -32,17 +42,27 @@ export const Banner = ({ item }: BannerProps) => { return item.bannerImage; } if ("coverImage" in item && item.coverImage) { - return item.coverImage.large || item.coverImage.medium; + return ( + item.coverImage.large || + item.coverImage.medium || + item.coverImage.color || + fallBackBanner + ); } if ("backdrop_path" in item && item.backdrop_path) { return item.backdrop_path; } - return "/fallback-banner.webp"; // Fallback in case there's no image available + return fallBackBanner; // Fallback in case there's no image available }; return (
-
+
{determineAlt()} { />
-
-
+
); }; +export default Banner; diff --git a/app/components/detail/card/AnimeCardDetail.tsx b/app/components/detail/card/AnimeCardDetail.tsx index e1357f0..d58ea29 100644 --- a/app/components/detail/card/AnimeCardDetail.tsx +++ b/app/components/detail/card/AnimeCardDetail.tsx @@ -1,19 +1,32 @@ -'use client' +"use client"; -import { useState } from 'react' -import Link from "next/link" -import Image from 'next/image' +import { useState } from "react"; +import Link from "next/link"; +import Image from "next/image"; interface AnimeCardDetailProps { - animeImage: string - episodeId: string - id: string - episodeNumber: number + animeImage: string; + episodeId: string; + id: string; + episodeNumber: number; } -export const AnimeCardDetail = ({ animeImage, episodeId, id, episodeNumber }: AnimeCardDetailProps) => { - const [isImageLoading, setImageLoading] = useState(true) - const route = { pathname: `/anime/watch/${episodeId}`, query: { title: id, ep: episodeNumber } } +export const AnimeCardDetail = ({ + animeImage, + episodeId, + id, + episodeNumber, +}: AnimeCardDetailProps) => { + const [isImageLoading, setImageLoading] = useState(true); + const isDub = episodeId.includes("-dub"); + const route = { + pathname: `/anime/watch`, + query: { + id: id, + ep: episodeNumber, + ...(isDub && { isDub: true }), // Only add isDub if it's true + }, + }; return ( @@ -29,9 +42,10 @@ export const AnimeCardDetail = ({ animeImage, episodeId, id, episodeNumber }: An className={` object-cover transition-custom-blur - ${isImageLoading - ? 'scale-110 blur-2xl' - : 'scale-100 blur-0 group-hover:scale-110' + ${ + isImageLoading + ? "scale-110 blur-2xl" + : "scale-100 blur-0 group-hover:scale-110" } hover:scale-110 hover:duration-300 `} @@ -46,12 +60,12 @@ export const AnimeCardDetail = ({ animeImage, episodeId, id, episodeNumber }: An className={` line-clamp-2 truncate text-sm font-bold transition-opacity duration-500 - ${isImageLoading ? 'opacity-0' : 'opacity-100'} + ${isImageLoading ? "opacity-0" : "opacity-100"} `} > Episode {episodeNumber}

- ) -} + ); +}; diff --git a/app/components/detail/cardbanner/CardBanner.tsx b/app/components/detail/cardbanner/CardBanner.tsx index 0da8128..33be47a 100644 --- a/app/components/detail/cardbanner/CardBanner.tsx +++ b/app/components/detail/cardbanner/CardBanner.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import Image from "next/image"; import { AnimeInfo } from "@/types/anime.type"; import { MovieInfo, TVInfo } from "@/types/movies.type"; @@ -10,85 +10,107 @@ interface CardBannerProps { } function CardBanner({ item }: CardBannerProps) { - const pathName = usePathname() - const pathType = pathName.split('/')[1]; + const pathName = usePathname(); + const fallbackCard = "/fallback-card.webp"; const [isImageLoading, setImageLoading] = useState(true); - // Function to determine the title - const determineTitle = (): string => { - if ('title' in item) { - if (typeof item.title === 'object') { - return item.title.userPreferred || item.title.english || item.title.romaji || item.title.native || 'Unknown Title'; + + const isAnime = useMemo(() => pathName.split("/")[1] === "anime", [pathName]); + + const title = useMemo(() => { + if ("title" in item) { + if (typeof item.title === "object") { + return ( + item.title?.userPreferred || + item.title?.english || + item.title?.romaji || + item.title?.native + ); } - return item.title || "Unknown Title"; + return item.title; } - if ('name' in item) { - return item.name; - } - return 'Unknown Title'; - }; + return "name" in item ? item.name : "Unknown Title"; + }, [item]); - // Function to get the correct image URL (Anime or Movie) - const getImageUrl = (): string => { - if ('coverImage' in item && item.coverImage) { - return item.coverImage.large || item.coverImage.medium; - } - if ('poster_path' in item && item.backdrop_path) { - return item.poster_path; + const imageUrl = useMemo(() => { + if ("coverImage" in item && item.coverImage) { + return ( + item.coverImage?.large || + item.coverImage?.medium || + item.coverImage?.color || + fallbackCard + ); } - return '/fallback-card.webp'; // Fallback in case there's no image available - }; + return "poster_path" in item && item.poster_path + ? item.poster_path + : fallbackCard; + }, [item]); - // Function to get genres (handling both AnimeInfo and MovieInfo) - const getGenres = (): string[] => { - if ('genres' in item) { - // For AnimeInfo, genres are an array of strings - if (typeof item.genres[0] === 'string') { - return item.genres as string[]; + const genres = useMemo(() => { + if ("genres" in item) { + if (item.genres?.length > 0) { + return typeof item.genres[0] === "string" + ? (item.genres as string[]) + : (item.genres as { name: string }[]).map((genre) => genre.name); } - // For MovieInfo, genres are an array of objects - if (typeof item.genres[0] === 'object' && item.genres[0]["name"]) { - return (item as MovieInfo).genres.map((genre) => genre.name); + const mergedArray = []; + if ("id_provider" in item) { + mergedArray.push(...item.tags.slice(0, 2).map((tag) => tag.name)); } + if ("imdb_id" in item) { + const spokenLang = (item as MovieInfo).spoken_languages + ?.slice(0, 2) + .map((lang) => lang.english_name); + const originCountry = (item as MovieInfo).origin_country?.slice(0, 2); + mergedArray.push(...(spokenLang || []), ...(originCountry || [])); + } + return mergedArray; } return []; - }; + }, [item]); return ( - <> -
-
- {determineTitle()} setImageLoading(false)} - className={` - transition-custom-blur - ${isImageLoading - ? 'scale-110 blur-2xl' - : 'scale-100 group-hover:scale-110'} - min-h-32 w-24 rounded object-cover blur-0 lg:w-40`} - /> -
-
-

- {determineTitle()} -

-
- {getGenres().slice(0, 3).map((genre: string, i: number) => ( - - {genre} - - ))} -
+
+
+ {title setImageLoading(false)} + className={` + transition-custom-blur ${ + isImageLoading ? "scale-110 blur-2xl" : "scale-100 blur-0" + } rounded group-hover:scale-110 + `} + /> +
+
+

+ {title} +

+
+ {genres.slice(0, 3).map((genre: string, i: number) => ( +
+

{genre}

+
+ ))}
- +
); } export default CardBanner; - diff --git a/app/components/detail/episodes/EpisodesContainer.tsx b/app/components/detail/episodes/EpisodesContainer.tsx index 5012229..bc5cd4c 100644 --- a/app/components/detail/episodes/EpisodesContainer.tsx +++ b/app/components/detail/episodes/EpisodesContainer.tsx @@ -1,17 +1,21 @@ "use client"; import { useState, useEffect } from "react"; -import { AnimeDetails, AnimeInfo } from "@/types/anime.type" +import { AnimeDetails, AnimeInfo } from "@/types/anime.type"; import { AnimeCardDetail } from "../card/AnimeCardDetail"; import SkeletonEpisodes from "@/components/skeleton/SkeletonEpisodes"; import { useThemeStore } from "@/store/themeStore"; function EpisodesContainer(anime: AnimeInfo) { const { theme } = useThemeStore(); - const [provider, setProvider] = useState(anime.id_provider?.idGogo); + const [provider, setProvider] = useState( + anime.id_provider?.idGogo + ); const [episodes, setEpisodes] = useState(null); const [loading, setLoading] = useState(true); + const isWhiteMode = (): boolean => theme === "garden"; + useEffect(() => { async function fetchEpisodes() { if (provider) { @@ -19,9 +23,9 @@ function EpisodesContainer(anime: AnimeInfo) { try { const response = await fetch(`/api/anime-infov1?query=${provider}`); if (!response.ok) { - throw new Error('Failed to fetch search results') + throw new Error("Failed to fetch search results"); } - const data = await response.json() + const data = await response.json(); setEpisodes(data); } catch (error) { console.error("Failed to fetch episodes:", error); @@ -37,42 +41,51 @@ function EpisodesContainer(anime: AnimeInfo) { return (
- { - anime.id_provider?.idGogoDub && ( - - ) - } - { - anime.id_provider?.idGogoDub && ( - - ) - } + {anime.id_provider?.idGogoDub && ( + + )} + {anime.id_provider?.idGogoDub && ( + + )}
{loading ? ( - ) : episodes ? ( + ) : episodes?.episodes.length ? (
{episodes.episodes.map((episode) => ( ))} diff --git a/app/components/detail/infodetails/InfoDetails.tsx b/app/components/detail/infodetails/InfoDetails.tsx index 66cbf6f..0d063ae 100644 --- a/app/components/detail/infodetails/InfoDetails.tsx +++ b/app/components/detail/infodetails/InfoDetails.tsx @@ -1,67 +1,112 @@ "use client"; -import Infos from './Infos'; -import parse from 'html-react-parser'; -import { useThemeStore } from '@/store/themeStore'; -import { formatDesc, formatDuration, formatDate } from '@/utils/formatted'; -import { MediaItem, getMediaType, isAnimeInfo, isMovieInfo, isTVInfo } from '@/utils/mediaTypeChecker'; +import Infos from "./Infos"; +import parse from "html-react-parser"; +import { useThemeStore } from "@/store/themeStore"; +import { formatDesc, formatDuration, formatDate } from "@/utils/formatted"; +import { + MediaItem, + getMediaType, + isAnimeInfo, + isMovieInfo, + isTVInfo, +} from "@/utils/mediaTypeChecker"; import fallbackDesc from "@/utils/fallbackDesc.json"; type InfoDetailsProps = { item: MediaItem; -} +}; const InfoDetails: React.FC = ({ item }) => { const { theme } = useThemeStore(); const mediaType = getMediaType(item); - const startDate = isAnimeInfo(item) && item.startIn - ? formatDate(new Date( - item.startIn.year ?? 0, - (item.startIn.month ?? 1) - 1, - item.startIn.day ?? 1 - )) - : isTVInfo(item) ? formatDate(item.first_air_date) : 'unknown'; + const releaseDate = isMovieInfo(item) ? item.release_date : undefined; - const endDate = isAnimeInfo(item) && item.endIn - ? formatDate(new Date( - item.endIn.year ?? 0, - (item.endIn.month ?? 1) - 1, - item.endIn.day ?? 1 - )) - : isTVInfo(item) ? formatDate(item.last_air_date) : 'unknown'; + const startDate = + isAnimeInfo(item) && item.startIn + ? formatDate( + new Date( + item.startIn.year ?? 0, + (item.startIn.month ?? 1) - 1, + item.startIn.day ?? 1 + ) + ) + : isTVInfo(item) + ? formatDate(item.first_air_date) + : "unknown"; + const endDate = + isAnimeInfo(item) && item.endIn + ? formatDate( + new Date( + item.endIn.year ?? 0, + (item.endIn.month ?? 1) - 1, + item.endIn.day ?? 1 + ) + ) + : isTVInfo(item) + ? formatDate(item.last_air_date) + : "unknown"; - const description = isAnimeInfo(item) ? item.description : item.overview || fallbackDesc; - const status = item.status?.replace(/_/g, " ") ?? 'unknown'; - const score = isAnimeInfo(item) ? item.score?.decimalScore : item.vote_average ?? "unknown"; + const description = isAnimeInfo(item) + ? item.description + : item.overview || fallbackDesc; + const status = item.status?.replace(/_/g, " ") ?? "unknown"; + const score = isAnimeInfo(item) + ? item.score?.decimalScore + : item.vote_average ?? "unknown"; const format = isAnimeInfo(item) ? item.format : mediaType; - const duration = isAnimeInfo(item) ? item.duration : isMovieInfo(item) ? item.runtime : 0; - const episodes = isAnimeInfo(item) ? `${item.episodes ? `${item.episodes} Episodes` : "Unknown"}` : isTVInfo(item) ? `${item.number_of_episodes} Episodes` : "unknown"; - const season = isAnimeInfo(item) ? item.season : isTVInfo(item) ? `${item.number_of_seasons} Seasons` : "?"; - const studios = isAnimeInfo(item) ? item.studios[0]?.name : item.production_companies[0]?.name ?? "unknown"; + const duration = isAnimeInfo(item) + ? item.duration + : isMovieInfo(item) + ? item.runtime + : 0; + const episodes = isAnimeInfo(item) + ? `${item.episodes ? `${item.episodes} Episodes` : "Unknown"}` + : isTVInfo(item) + ? `${item.number_of_episodes} Episodes` + : "unknown"; + const season = isAnimeInfo(item) + ? item.season + : isTVInfo(item) + ? `${item.number_of_seasons} Seasons` + : "?"; + const studios = isAnimeInfo(item) + ? item.studios[0]?.name + : item.production_companies[0]?.name ?? "unknown"; const cleanSynopsis = formatDesc(description); const borderClass = theme === "black" ? "border-gray-200" : "border-gray-700"; - const scoreThemeClass = theme === "garden" ? "text-green-600" : "text-green-400"; + const scoreThemeClass = + theme === "garden" ? "text-green-600" : "text-green-400"; return (
- +

/

10

- - {(mediaType === 'movie' || mediaType === 'anime') && ( + + {releaseDate?.length && ( + + )} + {(mediaType === "movie" || mediaType === "anime") && ( )} - {mediaType !== 'movie' && ( + {mediaType !== "movie" && ( <> @@ -69,16 +114,26 @@ const InfoDetails: React.FC = ({ item }) => { )} - + {isAnimeInfo(item) && item.title.native && ( + + )} + {isAnimeInfo(item) && item.title.romaji && ( + + )} +
-

Synopsis

+

Synopsis

{parse(cleanSynopsis ?? "unknown")}

); -} +}; export default InfoDetails; diff --git a/app/components/detail/infodetails/Infos.tsx b/app/components/detail/infodetails/Infos.tsx index bb7f74e..250e91b 100644 --- a/app/components/detail/infodetails/Infos.tsx +++ b/app/components/detail/infodetails/Infos.tsx @@ -1,22 +1,22 @@ type Infos = { topic: string; - value: string | number; + value?: string | number; customTheme?: string; -} +}; function Infos({ topic, value, customTheme }: Infos) { return ( <>
-

- {topic} -

-

+

{topic}

+

{value ?? "unknown"}

- ) + ); } -export default Infos +export default Infos; diff --git a/app/components/detail/infodetails/Rate.tsx b/app/components/detail/infodetails/Rate.tsx new file mode 100644 index 0000000..2408b60 --- /dev/null +++ b/app/components/detail/infodetails/Rate.tsx @@ -0,0 +1,18 @@ +import { useThemeStore } from "@/store/themeStore"; + +function Rate({ rate = "unknown" }: { rate: string }) { + const { theme } = useThemeStore(); + return ( +

+ {rate} +

+ ); +} + +export default Rate; diff --git a/app/components/detail/infodetails/SmallInfo.tsx b/app/components/detail/infodetails/SmallInfo.tsx new file mode 100644 index 0000000..735a480 --- /dev/null +++ b/app/components/detail/infodetails/SmallInfo.tsx @@ -0,0 +1,23 @@ +import Rate from "./Rate"; + +function SmallInfo({ + year = "unknown", + genre = "unknown", + rating = "unknown", +}: { + year: string; + genre: string; + rating: string; +}) { + return ( +
+
+

{year}

◦ +

{genre}

+
+ +
+ ); +} + +export default SmallInfo; diff --git a/app/components/detail/relation/RelationComponent.tsx b/app/components/detail/relation/RelationComponent.tsx index 2644264..86f5ca8 100644 --- a/app/components/detail/relation/RelationComponent.tsx +++ b/app/components/detail/relation/RelationComponent.tsx @@ -1,19 +1,26 @@ -import { AnimeContainerCard } from "@/components/card/animecard/AnimeContainerCard"; -import { Relation } from "@/types/anime.type"; +import AnimeContainerCard from "@/components/card/animecard/AnimeContainerCard"; +import { RelationOrRecommendation } from "@/types/anime.type"; -function RelationComponent({ relation }: { relation: Relation[] }) { - const filteredRelation = relation.filter((i) => (i.format === "TV" || i.format === "MOVIE" || i.format === "SPECIAL")); +function RelationComponent({ + relation, +}: { + relation: RelationOrRecommendation[]; +}) { + const filteredRelation = relation.filter( + (i) => i.format === "TV" || i.format === "MOVIE" || i.format === "SPECIAL" + ); return ( <>
- { - (filteredRelation.length > 0) && ( - - ) - } + {filteredRelation.length > 0 && ( + + )}
- ) + ); } -export default RelationComponent +export default RelationComponent; diff --git a/app/components/docs/ModalDocs.tsx b/app/components/docs/ModalDocs.tsx index 0223e4e..cc8a856 100644 --- a/app/components/docs/ModalDocs.tsx +++ b/app/components/docs/ModalDocs.tsx @@ -54,7 +54,7 @@ const ModalDocs = () => { return ( <> {isOpen && pathname === "/" && ( - +
{/* Modal content remains the same */}
diff --git a/app/components/genre/Genre.tsx b/app/components/genre/Genre.tsx index f34b0e1..1acc85d 100644 --- a/app/components/genre/Genre.tsx +++ b/app/components/genre/Genre.tsx @@ -5,7 +5,7 @@ type Genre = { function Genre({ genre }: Genre) { return ( {genre} diff --git a/app/components/genre/SearchedGenre.tsx b/app/components/genre/SearchedGenre.tsx index 06c3b55..99d166a 100644 --- a/app/components/genre/SearchedGenre.tsx +++ b/app/components/genre/SearchedGenre.tsx @@ -1,14 +1,21 @@ -function SearchedGenre({ genre = "unknown", theme = "garden" }: { genre: string, theme: string }) { +import { useThemeStore } from "@/store/themeStore"; + +function SearchedGenre({ genre = "unknown" }: { genre: string }) { + const { theme } = useThemeStore(); return ( <> {genre} - ) + ); } -export default SearchedGenre +export default SearchedGenre; diff --git a/app/components/hero/HeroMedia.tsx b/app/components/hero/HeroMedia.tsx index 37785ea..3364f90 100644 --- a/app/components/hero/HeroMedia.tsx +++ b/app/components/hero/HeroMedia.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import Image from "next/image"; import Link from "next/link"; -import parse from 'html-react-parser'; +import parse from "html-react-parser"; import Genre from "@/components/genre/Genre"; import { Title, CoverImage } from "@/types/anime.type"; import { usePathname } from "next/navigation"; @@ -16,34 +16,43 @@ type MediaProps = { coverImage: string | CoverImage; }; -function HeroMedia({ id, title, description, bannerImage, coverImage, genres }: MediaProps) { +function HeroMedia({ + id, + title, + description, + bannerImage, + coverImage, + genres, +}: MediaProps) { const [isImageLoading, setImageLoading] = useState(true); const pathName = usePathname(); - const pathType = pathName.split('/')[1]; // This gives you either 'movie' or 'tv' - const cleanDesc = description?.replace(//gi, ''); + const pathType = pathName.split("/")[1]; // This gives you either 'movie' or 'tv' + const cleanDesc = description?.replace(//gi, ""); const displayTitle = typeof title === "string" ? title : title.userPreferred; - const displayCoverImage = typeof coverImage === "string" ? coverImage : coverImage.extraLarge; + const displayCoverImage = + typeof coverImage === "string" ? coverImage : coverImage.extraLarge; const determineNavigateTo = (): string => { if (pathType.toLowerCase() === "anime") return `/anime/detail/${id}`; // NOTE: the hero is only provide the Movie data not the Series if (pathType.toLowerCase() === "series") return `/series/detail/${id}`; - return `/movie/detail/${id}` - } + return `/movie/detail/${id}`; + }; return (
{(bannerImage || displayCoverImage) && ( {displayTitle} setImageLoading(false)} - className={`inset-0 transition-all duration-700 ease-in-out z-0 h-full w-full object-cover ${isImageLoading ? "blur-3xl" : "blur-0"}`} + className={`inset-0 transition-all duration-700 ease-in-out z-0 h-full w-full object-cover ${ + isImageLoading ? "blur-3xl" : "blur-0" + }`} /> - )}
@@ -61,7 +70,7 @@ function HeroMedia({ id, title, description, bannerImage, coverImage, genres }: ))}
-
+
Watch
@@ -70,4 +79,3 @@ function HeroMedia({ id, title, description, bannerImage, coverImage, genres }: } export default HeroMedia; - diff --git a/app/components/hero/HeroMediaCarousel.tsx b/app/components/hero/HeroMediaCarousel.tsx index e7ad328..c942e60 100644 --- a/app/components/hero/HeroMediaCarousel.tsx +++ b/app/components/hero/HeroMediaCarousel.tsx @@ -1,7 +1,7 @@ "use client"; import useEmblaCarousel from "embla-carousel-react"; -import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; +import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"; import Autoplay from "embla-carousel-autoplay"; import { useEffect } from "react"; import HeroMedia from "./HeroMedia"; @@ -15,7 +15,10 @@ type MediaCarouselProps = { }; export default function HeroMediaCarousel({ items }: MediaCarouselProps) { - const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [WheelGesturesPlugin(), Autoplay()]); + const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [ + WheelGesturesPlugin(), + Autoplay(), + ]); useEffect(() => { if (emblaApi) { @@ -24,27 +27,41 @@ export default function HeroMediaCarousel({ items }: MediaCarouselProps) { }, [emblaApi, items]); return ( -
+
{items.map((item) => ( - - ))}
); } - diff --git a/app/components/icons/AnimeIcon.tsx b/app/components/icons/AnimeIcon.tsx deleted file mode 100644 index a98a82e..0000000 --- a/app/components/icons/AnimeIcon.tsx +++ /dev/null @@ -1,9 +0,0 @@ -function AnimeIcon() { - return ( - <> - - - ) -} - -export default AnimeIcon; diff --git a/app/components/icons/DocsIcon.tsx b/app/components/icons/DocsIcon.tsx deleted file mode 100644 index ee1887f..0000000 --- a/app/components/icons/DocsIcon.tsx +++ /dev/null @@ -1,9 +0,0 @@ -function DocsIcon() { - return ( - <> - - - ) -} - -export default DocsIcon; diff --git a/app/components/icons/GithubIcon.tsx b/app/components/icons/GithubIcon.tsx deleted file mode 100644 index c38e307..0000000 --- a/app/components/icons/GithubIcon.tsx +++ /dev/null @@ -1,9 +0,0 @@ -function GithubIcon() { - return ( - <> - - - ) -} - -export default GithubIcon; diff --git a/app/components/icons/MoviesIcon.tsx b/app/components/icons/MoviesIcon.tsx deleted file mode 100644 index b7ee6b3..0000000 --- a/app/components/icons/MoviesIcon.tsx +++ /dev/null @@ -1,9 +0,0 @@ -function MoviesIcon() { - return ( - <> - - - ) -} - -export default MoviesIcon diff --git a/app/components/icons/SearchIcon.tsx b/app/components/icons/SearchIcon.tsx deleted file mode 100644 index f81342a..0000000 --- a/app/components/icons/SearchIcon.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react' - -function SearchIcon() { - return ( - <> - - - - - ) -} - -export default SearchIcon diff --git a/app/components/media/Embeded.tsx b/app/components/media/Embeded.tsx index 13986e7..a2dda21 100644 --- a/app/components/media/Embeded.tsx +++ b/app/components/media/Embeded.tsx @@ -1,30 +1,44 @@ -import { getEnv } from "@/utils/getEnv" +import { getEnv } from "@/utils/getEnv"; type EmbededProps = { id: string; type: "movie" | "tv"; - season?: number; - ep?: number; -} + season?: string; + ep?: string; +}; -function Embeded({ id, type, season = 1, ep = 1 }: EmbededProps) { +function Embeded({ id, type, season = "1", ep = "1" }: EmbededProps) { const determinePathQuery = (): string => { - if (type.toLowerCase() === "tv") return `/tv?tmdb=${id}&season=${season}&episode=${ep}` + if (type.toLowerCase() === "tv") return `/tv/${id}/${season}/${ep}`; return `/movie/${id}`; - } + }; - const source = `${(getEnv("WATCHLO_SOURCE_EMBED") ?? process.env["NEXT_PUBLIC_WATCHLO_SOURCE_EMBED"])}${determinePathQuery()}` + const source = `${ + getEnv("WATCHLO_SOURCE_EMBED") ?? + process.env["NEXT_PUBLIC_WATCHLO_SOURCE_EMBED"] + }${determinePathQuery()}`; return ( <> -
- { - type.toLowerCase() === "movie" && +
+ {type.toLowerCase() === "movie" && (

Watch 🎬

- } - + )} +
- ) + ); } -export default Embeded +export default Embeded; diff --git a/app/components/media/Media.tsx b/app/components/media/Media.tsx index a1e1902..4829247 100644 --- a/app/components/media/Media.tsx +++ b/app/components/media/Media.tsx @@ -1,70 +1,201 @@ -"use client"; -import { StreamInfo } from '@/types/anime.type'; -import { MediaPlayer, MediaPlayerInstance, MediaProvider, Poster } from '@vidstack/react'; -import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default'; -import { useRef, useState, useEffect } from 'react'; -import Quality from '../quality/Quality'; - -interface MediaProps extends StreamInfo { +import { StreamInfo } from "@/types/anime.type"; +import { + Gesture, + MediaPlayer, + MediaPlayerInstance, + MediaProvider, + Poster, +} from "@vidstack/react"; +import { + defaultLayoutIcons, + DefaultVideoLayout, +} from "@vidstack/react/player/layouts/default"; +import Hls, { ErrorData, Events } from "hls.js"; +import { useRef, useState, useEffect } from "react"; +import Quality from "../quality/Quality"; +import useSWR from "swr"; +import SkeletonMediaPlayer from "../skeleton/SkeletonMediaPlayer"; + +interface MediaProps { poster: string; title: string; + episodeId: string; + ep: string; } -export const Media = ({ title, poster, sources }: MediaProps) => { +const fetchAnimeStream = async (episodeId: string | null) => { + if (!episodeId) return null; + const res = await fetch(`/api/animestream?query=${episodeId}`); + return res.json(); +}; + +const Media = ({ title, poster, episodeId, ep }: MediaProps) => { const playerRef = useRef(null); - const [vidSrc, setVidSrc] = useState( - (sources.find(source => source.quality === "default") ?? sources[0])?.url || '' - ); + const hlsRef = useRef(null); + const [vidSrc, setVidSrc] = useState(""); const [currentTime, setCurrentTime] = useState(0); - const [, setIsReady] = useState(false); + const previousEp = useRef(""); + + const { + data: streamInfo, + error: errorStream, + isLoading, + } = useSWR(() => episodeId || null, fetchAnimeStream); + + useEffect(() => { + if (previousEp.current !== null && previousEp.current !== ep) { + setCurrentTime(0); + } + previousEp.current = ep; + }, [ep]); useEffect(() => { + if (streamInfo?.sources && streamInfo?.sources.length > 0) { + const defaultSource = + streamInfo?.sources.find((source) => source.quality === "default") ?? + streamInfo?.sources[0]; + setVidSrc(defaultSource.url); + } + }, [episodeId, streamInfo?.sources]); + + useEffect(() => { + if (!vidSrc) return; + const player = playerRef.current; - if (player) { - const onCanPlay = () => { - setIsReady(true); - player.currentTime = currentTime; - if (player.paused) { - player.play().catch(error => console.error('Error playing video:', error)); + if (!player) return; + + const initializeHls = () => { + if (!Hls.isSupported() || !vidSrc.includes(".m3u8")) return; + + const hls = new Hls({ + maxBufferSize: 30 * 1000 * 1000, + enableWorker: true, + startLevel: -1, + lowLatencyMode: true, + backBufferLength: 90, + abrEwmaDefaultEstimate: 1000000, + abrBandWidthFactor: 0.95, + abrBandWidthUpFactor: 0.7, + fragLoadingTimeOut: 20000, + manifestLoadingTimeOut: 20000, + }); + + hls.loadSource(vidSrc); + + const checkProviderAndInitialize = () => { + if (!player.provider) { + requestAnimationFrame(checkProviderAndInitialize); + return; } - }; - player.addEventListener('can-play', onCanPlay); + const mediaElement = + player.provider?.type === "video" || player.provider?.type === "hls" + ? player.provider?.video + : null; + + if (!mediaElement) { + console.error("No media element found"); + return; + } + + hls.attachMedia(mediaElement); + + hls.on(Events.MANIFEST_PARSED, () => { + player.currentTime = currentTime; + player.play().catch((playError: Error) => { + console.error("Error playing video:", playError); + }); + }); - // Clean up the event listener - return () => { - player.removeEventListener('can-play', onCanPlay); + hls.on(Hls.Events.ERROR, (_event: Events, data: ErrorData) => { + if (data.fatal) { + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + hls.startLoad(); + break; + case Hls.ErrorTypes.MEDIA_ERROR: + hls.recoverMediaError(); + break; + default: + initializeHls(); + break; + } + } + }); + + hlsRef.current = hls; }; - } + + checkProviderAndInitialize(); + }; + + initializeHls(); + + return () => { + if (hlsRef.current) { + hlsRef.current.destroy(); + hlsRef.current = null; + } + }; }, [vidSrc, currentTime]); const handleQualityChange = (newSrc: string): void => { const player = playerRef.current; if (player) { setCurrentTime(player.currentTime); - setIsReady(false); setVidSrc(newSrc); } }; + if (errorStream) { + return ( +
There is an error in our application :[
+ ); + } + + if (episodeId && isLoading) { + return ; + } + + if (!episodeId || !streamInfo?.sources || streamInfo?.sources.length === 0) { + return
No video sources available
; + } + return ( - <> -
-

- {title} -

- - - - - - - -
- +
+

+ {title} +

+ + + + + + + + + + +
); }; + +export default Media; diff --git a/app/components/media/PlayButton.tsx b/app/components/media/PlayButton.tsx index 993f5cf..4ae744d 100644 --- a/app/components/media/PlayButton.tsx +++ b/app/components/media/PlayButton.tsx @@ -2,12 +2,16 @@ function PlayButton() { return ( <>
- +
- ) + ); } -export default PlayButton +export default PlayButton; diff --git a/app/components/media/Trailer.tsx b/app/components/media/Trailer.tsx index 262f35b..70886b9 100644 --- a/app/components/media/Trailer.tsx +++ b/app/components/media/Trailer.tsx @@ -1,28 +1,31 @@ "use client"; -import { AnimeInfo } from '@/types/anime.type' -import { useState } from 'react' -import Image from 'next/image' +import { AnimeInfo } from "@/types/anime.type"; +import { useState } from "react"; +import Image from "next/image"; import PlayButton from "./PlayButton"; -import { Video } from '@/types/movies.type'; -import { usePathname } from 'next/navigation'; +import { Video } from "@/types/movies.type"; +import { usePathname } from "next/navigation"; function Trailer({ trailer }: { trailer: AnimeInfo["trailer"] | Video }) { const [isImageLoading, setImageLoading] = useState(true); const pathName = usePathname(); - const pathType = pathName.split('/')[1]; // This gives you either 'movie' or 'tv' - const isMoviePath = pathType.toLowerCase() === "movie" || pathType.toLowerCase() === "series"; + const pathType = pathName.split("/")[1]; // This gives you either 'movie' or 'tv' + const isMoviePath = + pathType.toLowerCase() === "movie" || pathType.toLowerCase() === "series"; const [isPlaying, setIsPlaying] = useState(false); if (!trailer || !trailer.id) { return null; // Don't render anything if there's no trailer } - let videoUrl = ''; + let videoUrl = ""; switch (trailer.site?.toLowerCase()) { - case 'youtube': - videoUrl = `https://www.youtube.com/embed/${"key" in trailer ? trailer.key : trailer.id ?? "xvFZjo5PgG0"}?autoplay=1`; + case "youtube": + videoUrl = `https://www.youtube.com/embed/${ + "key" in trailer ? trailer.key : trailer.id ?? "xvFZjo5PgG0" + }?autoplay=0`; break; - case 'dailymotion': + case "dailymotion": videoUrl = `https://www.dailymotion.com/embed/video/${trailer.id}?autoplay=1`; break; default: @@ -30,15 +33,22 @@ function Trailer({ trailer }: { trailer: AnimeInfo["trailer"] | Video }) { } const handlePlay = () => { - setIsPlaying(true) - } + setIsPlaying(true); + }; return (

Trailer 💡

-
+
{!isPlaying ? ( -
+
setImageLoading(false)} - className={`transition-custom-blur object-cover rounded ${isImageLoading ? "blur-3xl" : "blur-0"}`} + className={`transition-custom-blur object-cover rounded ${ + isImageLoading ? "blur-3xl" : "blur-0" + }`} />
@@ -61,7 +73,7 @@ function Trailer({ trailer }: { trailer: AnimeInfo["trailer"] | Video }) { )}
- ) + ); } export default Trailer; diff --git a/app/components/navbar/Menu.tsx b/app/components/navbar/Menu.tsx index bea678c..9c3f87b 100644 --- a/app/components/navbar/Menu.tsx +++ b/app/components/navbar/Menu.tsx @@ -1,23 +1,33 @@ import { useThemeStore } from "@/store/themeStore"; import Link from "next/link"; -import DocsIcon from "../icons/DocsIcon"; -import AnimeIcon from "../icons/AnimeIcon"; -import MoviesIcon from "../icons/MoviesIcon"; -import GithubIcon from "../icons/GithubIcon"; +import { GiFilmSpool } from "react-icons/gi"; +import { GiShintoShrine } from "react-icons/gi"; +import { GiNotebook } from "react-icons/gi"; +import { FaSquareGithub } from "react-icons/fa6"; import { motion, AnimatePresence } from "framer-motion"; -function Menu({ isToggled = false }) { +function Menu({ + isToggled = false, + closeMenu, +}: { + isToggled: boolean; + closeMenu: () => void; +}) { const { theme } = useThemeStore(); const menuItems = [ - { href: "/", icon: , label: "Movies" }, - { href: "/anime", icon: , label: "Anime (No ads)" }, - { href: "/docs", icon: , label: "Docs" }, + { href: "/", icon: , label: "Movies" }, + { + href: "/anime", + icon: , + label: "Anime (No ads)", + }, + { href: "/docs", icon: , label: "Docs" }, { href: "https://github.com/alfaruqii/watchlo", - icon: , + icon: , label: "Github", - external: true + external: true, }, ]; @@ -32,7 +42,11 @@ function Menu({ isToggled = false }) { className={` fixed left-0 right-0 top-12 z-50 w-full overflow-hidden border-b pb-2 - ${theme === "garden" ? "bg-base-100 border-gray-700/20" : "bg-black border-gray-300/20"} + ${ + theme === "garden" + ? "bg-base-100 border-gray-700/20" + : "bg-black border-gray-300/20" + } drop-shadow-xl `} > @@ -43,17 +57,19 @@ function Menu({ isToggled = false }) { animate={{ opacity: 1, y: 0, x: 0 }} transition={{ delay: index * 0.08 }} > - closeMenu()}> + - - {item.icon} - - {item.label} - + > + + {item.icon} + + {item.label} + + ))} diff --git a/app/components/navbar/Navbar.tsx b/app/components/navbar/Navbar.tsx index 7df2620..f3083d5 100644 --- a/app/components/navbar/Navbar.tsx +++ b/app/components/navbar/Navbar.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import Image from "next/image"; import { Spin as Hamburger } from "hamburger-react"; import { ToggleTheme } from "./ToggleTheme"; import { useThemeStore } from "@/store/themeStore"; @@ -11,57 +11,87 @@ import Menu from "./Menu"; const Navbar = () => { const [isOpen, setOpen] = useState(false); const { theme } = useThemeStore(); - const pathname = usePathname(); - - // Close menu on route change - useEffect(() => { - setOpen(false); - }, [pathname]); const navLinks = [ { href: "/", label: "Movies" }, { - href: "/anime", label: "Anime", - badge: { text: "No ads", theme } + href: "/anime", + label: "Anime", + badge: { text: "No ads", theme }, }, { href: "/manga", label: "Manga", disabled: true, - badge: { text: "DEV", theme } + badge: { text: "DEV", theme }, }, { href: "/docs", label: "Docs" }, { href: "https://github.com/alfaruqii/watchlo", label: "Github", - external: true + external: true, }, ]; const textClass = theme === "black" ? "text-white" : "text-black"; + const closeMenu = (): void => setOpen(false); return ( <> - -
- - Watch - milo + +
+ -