diff --git a/apps/expo/src/app/(tabs)/_layout.tsx b/apps/expo/src/app/(tabs)/_layout.tsx index 9ec283b..400ec09 100644 --- a/apps/expo/src/app/(tabs)/_layout.tsx +++ b/apps/expo/src/app/(tabs)/_layout.tsx @@ -9,6 +9,7 @@ import { defaultTheme } from "@movie-web/tailwind-config/themes"; import { MovieWebSvg } from "~/components/Icon"; import SvgTabBarIcon from "~/components/SvgTabBarIcon"; import TabBarIcon from "~/components/TabBarIcon"; +import { cn } from "~/lib/utils"; import SearchTabContext from "../../components/ui/SearchTabContext"; export default function TabLayout() { @@ -83,7 +84,13 @@ export default function TabLayout() { tabBarLabel: "", tabBarIcon: ({ focused }) => ( diff --git a/apps/expo/src/components/player/Header.tsx b/apps/expo/src/components/player/Header.tsx index ac0dbdc..d983aa7 100644 --- a/apps/expo/src/components/player/Header.tsx +++ b/apps/expo/src/components/player/Header.tsx @@ -16,7 +16,7 @@ export const Header = () => { if (!isIdle && meta) { return ( - + @@ -29,7 +29,7 @@ export const Header = () => { ) : ""} - + movie-web diff --git a/apps/expo/src/components/player/ScrapeCard.tsx b/apps/expo/src/components/player/ScrapeCard.tsx new file mode 100644 index 0000000..67bdcf7 --- /dev/null +++ b/apps/expo/src/components/player/ScrapeCard.tsx @@ -0,0 +1,119 @@ +import type { ReactNode } from "react"; +import React from "react"; +import { View } from "react-native"; +import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"; + +import { defaultTheme } from "@movie-web/tailwind-config/themes"; + +import { cn } from "~/lib/utils"; +import { Text } from "../ui/Text"; + +export interface ScrapeItemProps { + status: "failure" | "pending" | "notfound" | "success" | "waiting"; + name: string; + id?: string; + percentage?: number; + children?: ReactNode; +} + +export interface ScrapeCardProps extends ScrapeItemProps { + hasChildren?: boolean; +} + +const statusTextMap: Partial> = { + notfound: "Doesn't have the video", + failure: "Failed to scrape", + pending: "Checking for videos...", +}; + +const mapPercentageToIcon = (percentage: number) => { + const slice = Math.floor(percentage / 12.5); + return `circle-slice-${slice === 0 ? 1 : slice}`; +}; + +export function StatusCircle({ + type, + percentage, +}: { + type: ScrapeItemProps["status"]; + percentage: number; +}) { + return ( + <> + {type === "waiting" && ( + + )} + {type === "pending" && ( + + )} + {type === "failure" && ( + + )} + {type === "notfound" && ( + + )} + {type === "success" && ( + + )} + + ); +} + +export function ScrapeItem(props: ScrapeItemProps) { + const text = statusTextMap[props.status]; + + return ( + + + + + {props.name} + + + + + {text && {text}} + + {props.children} + + ); +} + +export function ScrapeCard(props: ScrapeCardProps) { + return ( + + + + + + ); +} diff --git a/apps/expo/src/components/player/ScraperProcess.tsx b/apps/expo/src/components/player/ScraperProcess.tsx index 2528094..57d7b33 100644 --- a/apps/expo/src/components/player/ScraperProcess.tsx +++ b/apps/expo/src/components/player/ScraperProcess.tsx @@ -1,145 +1,47 @@ -import { useCallback, useEffect, useState } from "react"; -import { ActivityIndicator, View } from "react-native"; +import { useEffect, useRef } from "react"; +import { SafeAreaView, View } from "react-native"; +import { ScrollView } from "react-native-gesture-handler"; import { useRouter } from "expo-router"; -import type { - DiscoverEmbedsEvent, - HlsBasedStream, - InitEvent, - RunnerEvent, - UpdateEvent, -} from "@movie-web/provider-utils"; -import { - extractTracksFromHLS, - getVideoStream, - transformSearchResultToScrapeMedia, -} from "@movie-web/provider-utils"; -import { fetchMediaDetails, fetchSeasonDetails } from "@movie-web/tmdb"; +import type { HlsBasedStream } from "@movie-web/provider-utils"; +import { extractTracksFromHLS } from "@movie-web/provider-utils"; import type { ItemData } from "../item/item"; import type { AudioTrack } from "./AudioTrackSelector"; +import { useMeta } from "~/hooks/player/useMeta"; +import { useScrape } from "~/hooks/player/useSourceScrape"; import { constructFullUrl } from "~/lib/url"; +import { cn } from "~/lib/utils"; import { PlayerStatus } from "~/stores/player/slices/interface"; +import { convertMetaToScrapeMedia } from "~/stores/player/slices/video"; import { usePlayerStore } from "~/stores/player/store"; -import { Text } from "../ui/Text"; +import { ScrapeCard, ScrapeItem } from "./ScrapeCard"; interface ScraperProcessProps { data: ItemData; } -enum ScrapeStatus { - LOADING = "loading", - SUCCESS = "success", - ERROR = "error", -} - export const ScraperProcess = ({ data }: ScraperProcessProps) => { const router = useRouter(); - const meta = usePlayerStore((state) => state.meta); + const scrollViewRef = useRef(null); + + const { convertMovieIdToMeta } = useMeta(); + const { startScraping, sourceOrder, sources, currentSource } = useScrape(); + const setStream = usePlayerStore((state) => state.setCurrentStream); - const setSeasonData = usePlayerStore((state) => state.setSeasonData); const setHlsTracks = usePlayerStore((state) => state.setHlsTracks); const setAudioTracks = usePlayerStore((state) => state.setAudioTracks); const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus); const setSourceId = usePlayerStore((state) => state.setSourceId); - const setMeta = usePlayerStore((state) => state.setMeta); - const [checkedSource, setCheckedSource] = useState(""); - - function isInitEvent(event: RunnerEvent): event is InitEvent { - return (event as InitEvent).sourceIds !== undefined; - } - - function isUpdateEvent(event: RunnerEvent): event is UpdateEvent { - return (event as UpdateEvent).percentage !== undefined; - } - - function isDiscoverEmbedsEvent( - event: RunnerEvent, - ): event is DiscoverEmbedsEvent { - return (event as DiscoverEmbedsEvent).sourceId !== undefined; - } - - const handleEvent = useCallback((event: RunnerEvent) => { - if (typeof event === "string") { - setCheckedSource(event); - setScrapeStatus({ status: ScrapeStatus.LOADING, progress: 10 }); - } else if (isUpdateEvent(event)) { - console.log(event.status); - switch (event.status) { - case "success": - setScrapeStatus({ status: ScrapeStatus.SUCCESS, progress: 100 }); - break; - case "failure": - setScrapeStatus({ status: ScrapeStatus.ERROR, progress: 0 }); - break; - case "pending": - case "notfound": - } - setCheckedSource(event.id); - } else if (isInitEvent(event) || isDiscoverEmbedsEvent(event)) { - setScrapeStatus((prevStatus) => ({ - status: ScrapeStatus.LOADING, - progress: Math.min(prevStatus.progress + 20, 95), - })); - } - }, []); - - const [_scrapeStatus, setScrapeStatus] = useState({ - status: ScrapeStatus.LOADING, - progress: 0, - }); useEffect(() => { const fetchData = async () => { if (!data) return router.back(); - const media = await fetchMediaDetails(data.id, data.type); - if (!media) return router.back(); - const scrapeMedia = transformSearchResultToScrapeMedia( - media.type, - media.result, - meta?.season?.number, - meta?.episode?.number, - ); - let seasonData = null; - if (scrapeMedia.type === "show") { - seasonData = await fetchSeasonDetails( - scrapeMedia.tmdbId, - scrapeMedia.season.number, - ); - } + const meta = await convertMovieIdToMeta(data.id, data.type); + if (!meta) return; + const streamResult = await startScraping(convertMetaToScrapeMedia(meta)); - setMeta({ - ...scrapeMedia, - poster: media.result.poster_path, - ...("season" in scrapeMedia - ? { - season: { - number: scrapeMedia.season.number, - tmdbId: scrapeMedia.tmdbId, - }, - episode: { - number: scrapeMedia.episode.number, - tmdbId: scrapeMedia.episode.tmdbId, - }, - episodes: - seasonData?.episodes.map((e) => ({ - tmdbId: e.id.toString(), - number: e.episode_number, - name: e.name, - })) ?? [], - } - : {}), - }); - const streamResult = await getVideoStream({ - media: scrapeMedia, - events: { - init: handleEvent, - update: handleEvent, - discoverEmbeds: handleEvent, - start: handleEvent, - }, - }); if (!streamResult) return router.back(); setStream(streamResult.stream); @@ -184,31 +86,82 @@ export const ScraperProcess = ({ data }: ScraperProcessProps) => { }; void fetchData(); }, [ + convertMovieIdToMeta, data, router, + setAudioTracks, setHlsTracks, - setSeasonData, - setStream, setPlayerStatus, setSourceId, - setMeta, - meta?.season?.number, - meta?.episode?.number, - setAudioTracks, - handleEvent, + setStream, + startScraping, ]); + let currentProviderIndex = sourceOrder.findIndex( + (s) => s.id === currentSource || s.children.includes(currentSource ?? ""), + ); + if (currentProviderIndex === -1) { + currentProviderIndex = sourceOrder.length - 1; + } + + useEffect(() => { + scrollViewRef.current?.scrollTo({ + y: currentProviderIndex * 80, + animated: true, + }); + }, [currentProviderIndex]); + return ( - - - - - Checking {checkedSource} - - - {/* */} - + + + + {sourceOrder.map((order) => { + const source = sources[order.id]; + if (!source) return null; + const distance = Math.abs( + sourceOrder.findIndex((o) => o.id === order.id) - + currentProviderIndex, + ); + return ( + + 0} + percentage={source.percentage} + > + 0, + })} + > + {order.children.map((embedId) => { + const embed = sources[embedId]; + if (!embed) return null; + return ( + + ); + })} + + + + ); + })} + - + ); }; diff --git a/apps/expo/src/components/ui/Button.tsx b/apps/expo/src/components/ui/Button.tsx index c80f904..5c02786 100644 --- a/apps/expo/src/components/ui/Button.tsx +++ b/apps/expo/src/components/ui/Button.tsx @@ -12,9 +12,9 @@ const buttonVariants = cva( { variants: { variant: { - default: "bg-primary-300", - outline: "border-primary-400 border bg-transparent", - secondary: "bg-secondary-300", + default: "bg-buttons-purple", + outline: "border border-buttons-purple bg-transparent", + secondary: "bg-buttons-secondary", }, size: { default: "h-10 px-4 py-2", diff --git a/apps/expo/src/hooks/player/useMeta.ts b/apps/expo/src/hooks/player/useMeta.ts new file mode 100644 index 0000000..8c3c78d --- /dev/null +++ b/apps/expo/src/hooks/player/useMeta.ts @@ -0,0 +1,58 @@ +import { useCallback } from "react"; + +import { transformSearchResultToScrapeMedia } from "@movie-web/provider-utils"; +import { fetchMediaDetails, fetchSeasonDetails } from "@movie-web/tmdb"; + +import { usePlayerStore } from "~/stores/player/store"; + +export const useMeta = () => { + const meta = usePlayerStore((state) => state.meta); + const setMeta = usePlayerStore((state) => state.setMeta); + + const convertMovieIdToMeta = useCallback( + async (id: string, type: "movie" | "tv") => { + const media = await fetchMediaDetails(id, type); + if (!media) return; + const scrapeMedia = transformSearchResultToScrapeMedia( + media.type, + media.result, + meta?.season?.number, + meta?.episode?.number, + ); + let seasonData = null; + if (scrapeMedia.type === "show") { + seasonData = await fetchSeasonDetails( + scrapeMedia.tmdbId, + scrapeMedia.season.number, + ); + } + const m = { + ...scrapeMedia, + poster: media.result.poster_path, + ...("season" in scrapeMedia + ? { + season: { + number: scrapeMedia.season.number, + tmdbId: scrapeMedia.tmdbId, + }, + episode: { + number: scrapeMedia.episode.number, + tmdbId: scrapeMedia.episode.tmdbId, + }, + episodes: + seasonData?.episodes.map((e) => ({ + tmdbId: e.id.toString(), + number: e.episode_number, + name: e.name, + })) ?? [], + } + : {}), + }; + setMeta(m); + return m; + }, + [meta?.episode?.number, meta?.season?.number, setMeta], + ); + + return { convertMovieIdToMeta }; +}; diff --git a/apps/expo/src/hooks/player/useSourceScrape.ts b/apps/expo/src/hooks/player/useSourceScrape.ts index 3e77e11..12ea24a 100644 --- a/apps/expo/src/hooks/player/useSourceScrape.ts +++ b/apps/expo/src/hooks/player/useSourceScrape.ts @@ -1,13 +1,194 @@ +import { useCallback, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + FullScraperEvents, + RunOutput, + ScrapeMedia, +} from "@movie-web/provider-utils"; import { + getMetaData, getVideoStreamFromEmbed, getVideoStreamFromSource, + providers, } from "@movie-web/provider-utils"; import { convertMetaToScrapeMedia } from "~/stores/player/slices/video"; import { usePlayerStore } from "~/stores/player/store"; +export interface ScrapingItems { + id: string; + children: string[]; +} + +export interface ScrapingSegment { + name: string; + id: string; + embedId?: string; + status: "failure" | "pending" | "notfound" | "success" | "waiting"; + reason?: string; + error?: any; + percentage: number; +} + +type ScraperEvent = Parameters< + NonNullable +>[0]; + +export const useBaseScrape = () => { + const [sources, setSources] = useState>({}); + const [sourceOrder, setSourceOrder] = useState([]); + const [currentSource, setCurrentSource] = useState(); + const lastId = useRef(null); + + const initEvent = useCallback((evt: ScraperEvent<"init">) => { + setSources( + evt.sourceIds + .map((v) => { + const source = getMetaData().find((s) => s.id === v); + if (!source) throw new Error("invalid source id"); + const out: ScrapingSegment = { + name: source.name, + id: source.id, + status: "waiting", + percentage: 0, + }; + return out; + }) + .reduce>((a, v) => { + a[v.id] = v; + return a; + }, {}), + ); + setSourceOrder(evt.sourceIds.map((v) => ({ id: v, children: [] }))); + }, []); + + const startEvent = useCallback((id: ScraperEvent<"start">) => { + const lastIdTmp = lastId.current; + setSources((s) => { + if (s[id]) s[id]!.status = "pending"; + if (lastIdTmp && s[lastIdTmp] && s[lastIdTmp]!.status === "pending") + s[lastIdTmp]!.status = "success"; + return { ...s }; + }); + setCurrentSource(id); + lastId.current = id; + }, []); + + const updateEvent = useCallback((evt: ScraperEvent<"update">) => { + setSources((s) => { + if (s[evt.id]) { + s[evt.id]!.status = evt.status; + s[evt.id]!.reason = evt.reason; + s[evt.id]!.error = evt.error; + s[evt.id]!.percentage = evt.percentage; + } + return { ...s }; + }); + }, []); + + const discoverEmbedsEvent = useCallback( + (evt: ScraperEvent<"discoverEmbeds">) => { + setSources((s) => { + evt.embeds.forEach((v) => { + const source = getMetaData().find( + (src) => src.id === v.embedScraperId, + ); + if (!source) throw new Error("invalid source id"); + const out: ScrapingSegment = { + embedId: v.embedScraperId, + name: source.name, + id: v.id, + status: "waiting", + percentage: 0, + }; + s[v.id] = out; + }); + return { ...s }; + }); + setSourceOrder((s) => { + const source = s.find((v) => v.id === evt.sourceId); + if (!source) throw new Error("invalid source id"); + source.children = evt.embeds.map((v) => v.id); + return [...s]; + }); + }, + [], + ); + + const startScrape = useCallback(() => { + lastId.current = null; + }, []); + + const getResult = useCallback((output: RunOutput | null) => { + if (output && lastId.current) { + setSources((s) => { + if (!lastId.current) return s; + if (s[lastId.current]) s[lastId.current]!.status = "success"; + return { ...s }; + }); + } + return output; + }, []); + + return { + initEvent, + startEvent, + updateEvent, + discoverEmbedsEvent, + startScrape, + getResult, + sources, + sourceOrder, + currentSource, + }; +}; + +export function useScrape() { + const { + sources, + sourceOrder, + currentSource, + updateEvent, + discoverEmbedsEvent, + initEvent, + getResult, + startEvent, + startScrape, + } = useBaseScrape(); + + const startScraping = useCallback( + async (media: ScrapeMedia) => { + startScrape(); + const output = await providers.runAll({ + media, + events: { + init: initEvent, + start: startEvent, + update: updateEvent, + discoverEmbeds: discoverEmbedsEvent, + }, + }); + return getResult(output); + }, + [ + initEvent, + startEvent, + updateEvent, + discoverEmbedsEvent, + getResult, + startScrape, + ], + ); + + return { + startScraping, + sourceOrder, + sources, + currentSource, + }; +} + export const useEmbedScrape = (closeModal?: () => void) => { const setCurrentStream = usePlayerStore((state) => state.setCurrentStream); diff --git a/packages/provider-utils/src/util.ts b/packages/provider-utils/src/util.ts index 5773d58..5979244 100644 --- a/packages/provider-utils/src/util.ts +++ b/packages/provider-utils/src/util.ts @@ -2,6 +2,12 @@ import type { AppendToResponse, MovieDetails, TvShowDetails } from "tmdb-ts"; import type { ScrapeMedia } from "@movie-web/providers"; +import { providers } from "./video"; + +export function getMetaData() { + return [...providers.listSources(), ...providers.listEmbeds()]; +} + export function transformSearchResultToScrapeMedia( type: T, result: T extends "tv" diff --git a/tooling/eslint/base.js b/tooling/eslint/base.js index fb6a6f7..079b795 100644 --- a/tooling/eslint/base.js +++ b/tooling/eslint/base.js @@ -29,6 +29,7 @@ const config = { { checksVoidReturn: { attributes: false } }, ], "import/consistent-type-specifier-style": ["error", "prefer-top-level"], + "@typescript-eslint/no-explicit-any": "off", }, ignorePatterns: [ "**/*.config.js",