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",