improve loading, caption renderer, season/episode selector, source selector

This commit is contained in:
Jorrin
2024-02-19 22:12:08 +01:00
parent efab11bff5
commit 90c6c2093b
31 changed files with 1453 additions and 824 deletions

View File

@@ -23,6 +23,7 @@
"@movie-web/tmdb": "*", "@movie-web/tmdb": "*",
"@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0", "@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0",
"@react-navigation/native": "^6.1.9", "@react-navigation/native": "^6.1.9",
"@tanstack/react-query": "^5.22.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"expo": "~50.0.5", "expo": "~50.0.5",
@@ -38,12 +39,12 @@
"expo-status-bar": "~1.11.1", "expo-status-bar": "~1.11.1",
"expo-web-browser": "^12.8.2", "expo-web-browser": "^12.8.2",
"immer": "^10.0.3", "immer": "^10.0.3",
"nativewind": "~4.0.23", "nativewind": "^4.0.35",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-native": "0.73.2", "react-native": "0.73.2",
"react-native-context-menu-view": "^1.14.1", "react-native-context-menu-view": "^1.14.1",
"react-native-css-interop": "~0.0.22", "react-native-css-interop": "^0.0.35",
"react-native-gesture-handler": "~2.14.1", "react-native-gesture-handler": "~2.14.1",
"react-native-modal": "^13.0.1", "react-native-modal": "^13.0.1",
"react-native-quick-base64": "^2.0.8", "react-native-quick-base64": "^2.0.8",
@@ -65,6 +66,7 @@
"@movie-web/prettier-config": "workspace:^0.1.0", "@movie-web/prettier-config": "workspace:^0.1.0",
"@movie-web/tailwind-config": "workspace:^0.1.0", "@movie-web/tailwind-config": "workspace:^0.1.0",
"@movie-web/tsconfig": "workspace:^0.1.0", "@movie-web/tsconfig": "workspace:^0.1.0",
"@tanstack/eslint-plugin-query": "^5.20.1",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"babel-plugin-module-resolver": "^5.0.0", "babel-plugin-module-resolver": "^5.0.0",

View File

@@ -6,6 +6,7 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useQuery } from "@tanstack/react-query";
import { getMediaPoster, searchTitle } from "@movie-web/tmdb"; import { getMediaPoster, searchTitle } from "@movie-web/tmdb";
@@ -16,18 +17,14 @@ import { Text } from "~/components/ui/Text";
import Searchbar from "./Searchbar"; import Searchbar from "./Searchbar";
export default function SearchScreen() { export default function SearchScreen() {
const [searchResults, setSearchResults] = useState<ItemData[]>([]); const [query, setQuery] = useState("");
const translateY = useSharedValue(0); const translateY = useSharedValue(0);
const fadeAnim = useSharedValue(1); const fadeAnim = useSharedValue(1);
const handleSearchChange = async (query: string) => { const { data } = useQuery({
if (query.length > 0) { queryKey: ["searchResults", query],
const results = await fetchSearchResults(query).catch(() => []); queryFn: () => fetchSearchResults(query),
setSearchResults(results); });
} else {
setSearchResults([]);
}
};
useEffect(() => { useEffect(() => {
const keyboardWillShowListener = Keyboard.addListener( const keyboardWillShowListener = Keyboard.addListener(
@@ -83,7 +80,7 @@ export default function SearchScreen() {
<ScrollView <ScrollView
onScrollBeginDrag={handleScrollBegin} onScrollBeginDrag={handleScrollBegin}
onMomentumScrollEnd={handleScrollEnd} onMomentumScrollEnd={handleScrollEnd}
scrollEnabled={searchResults.length > 0} scrollEnabled={data && data.length > 0}
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
@@ -95,7 +92,7 @@ export default function SearchScreen() {
} }
> >
<View className="flex w-full flex-1 flex-row flex-wrap justify-start"> <View className="flex w-full flex-1 flex-row flex-wrap justify-start">
{searchResults.map((item, index) => ( {data?.map((item, index) => (
<View key={index} className="basis-1/2 px-3 pb-3"> <View key={index} className="basis-1/2 px-3 pb-3">
<Item data={item} /> <Item data={item} />
</View> </View>
@@ -109,7 +106,7 @@ export default function SearchScreen() {
animatedStyle, animatedStyle,
]} ]}
> >
<Searchbar onSearchChange={handleSearchChange} /> <Searchbar onSearchChange={setQuery} />
</Animated.View> </Animated.View>
</View> </View>
); );

View File

@@ -10,6 +10,7 @@ import {
DefaultTheme, DefaultTheme,
ThemeProvider, ThemeProvider,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Colors from "@movie-web/tailwind-config/colors"; import Colors from "@movie-web/tailwind-config/colors";
@@ -30,6 +31,8 @@ SplashScreen.preventAutoHideAsync().catch(() => {
/* reloading the app might trigger this, so it's safe to ignore */ /* reloading the app might trigger this, so it's safe to ignore */
}); });
const queryClient = new QueryClient();
export default function RootLayout() { export default function RootLayout() {
const [loaded, error] = useFonts({ const [loaded, error] = useFonts({
OpenSansRegular: require("../../assets/fonts/OpenSans-Regular.ttf"), OpenSansRegular: require("../../assets/fonts/OpenSans-Regular.ttf"),
@@ -69,32 +72,34 @@ function RootLayoutNav() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
return ( return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}> <QueryClientProvider client={queryClient}>
<Stack <ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
screenOptions={{ <Stack
autoHideHomeIndicator: true, screenOptions={{
gestureEnabled: true,
animation: "default",
animationTypeForReplace: "push",
presentation: "card",
headerShown: false,
contentStyle: {
backgroundColor: Colors.background,
},
}}
>
<Stack.Screen
name="(tabs)"
options={{
headerShown: false,
autoHideHomeIndicator: true, autoHideHomeIndicator: true,
gestureEnabled: true, gestureEnabled: true,
animation: "default", animation: "default",
animationTypeForReplace: "push", animationTypeForReplace: "push",
presentation: "card", presentation: "card",
headerShown: false,
contentStyle: {
backgroundColor: Colors.background,
},
}} }}
/> >
</Stack> <Stack.Screen
</ThemeProvider> name="(tabs)"
options={{
headerShown: false,
autoHideHomeIndicator: true,
gestureEnabled: true,
animation: "default",
animationTypeForReplace: "push",
presentation: "card",
}}
/>
</Stack>
</ThemeProvider>
</QueryClientProvider>
); );
} }

View File

@@ -1,304 +1,26 @@
import type { AVPlaybackSource } from "expo-av";
import React, { useEffect, useState } from "react";
import {
ActivityIndicator,
Dimensions,
Platform,
StyleSheet,
View,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { runOnJS, useSharedValue } from "react-native-reanimated";
import { ResizeMode, Video } from "expo-av";
import * as NavigationBar from "expo-navigation-bar";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import * as StatusBar from "expo-status-bar";
import type { HLSTracks, ScrapeMedia, Stream } from "@movie-web/provider-utils";
import {
extractTracksFromHLS,
findHighestQuality,
} from "@movie-web/provider-utils";
import { fetchSeasonDetails } from "@movie-web/tmdb";
import type { ItemData } from "~/components/item/item"; import type { ItemData } from "~/components/item/item";
import type { HeaderData } from "~/components/player/Header"; import { ScraperProcess } from "~/components/player/ScraperProcess";
import { CaptionRenderer } from "~/components/player/CaptionRenderer"; import { VideoPlayer } from "~/components/player/VideoPlayer";
import { ControlsOverlay } from "~/components/player/ControlsOverlay"; import { usePlayer } from "~/hooks/player/usePlayer";
import { Text } from "~/components/ui/Text";
import { useBrightness } from "~/hooks/player/useBrightness";
import { useVolume } from "~/hooks/player/useVolume";
import { usePlayerStore } from "~/stores/player/store"; import { usePlayerStore } from "~/stores/player/store";
export default function VideoPlayerWrapper() { export default function VideoPlayerWrapper() {
const playerStatus = usePlayerStore((state) => state.interface.playerStatus);
const { presentFullscreenPlayer } = usePlayer();
const router = useRouter();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const data = params.data const data = params.data
? (JSON.parse(params.data as string) as VideoPlayerData) ? (JSON.parse(params.data as string) as ItemData)
: null; : null;
return <VideoPlayer data={data} />;
if (!data) return router.back();
void presentFullscreenPlayer();
if (playerStatus === "scraping") return <ScraperProcess data={data} />;
if (playerStatus === "ready") return <VideoPlayer />;
} }
export interface VideoPlayerData {
sourceId?: string;
item: ItemData;
stream: Stream;
media: ScrapeMedia;
}
interface VideoPlayerProps {
data: VideoPlayerData | null;
}
const VideoPlayer: React.FC<VideoPlayerProps> = ({ data }) => {
const {
brightness,
debouncedBrightness,
showBrightnessOverlay,
setShowBrightnessOverlay,
handleBrightnessChange,
} = useBrightness();
const {
currentVolume,
debouncedVolume,
showVolumeOverlay,
setShowVolumeOverlay,
handleVolumeChange,
} = useVolume();
const [videoSrc, setVideoSrc] = useState<AVPlaybackSource>();
const [isLoading, setIsLoading] = useState(true);
const [headerData, setHeaderData] = useState<HeaderData>();
const [resizeMode, setResizeMode] = useState(ResizeMode.CONTAIN);
const [shouldPlay, setShouldPlay] = useState(true);
const router = useRouter();
const scale = useSharedValue(1);
const isIdle = usePlayerStore((state) => state.interface.isIdle);
const setStream = usePlayerStore((state) => state.setStream);
const setVideoRef = usePlayerStore((state) => state.setVideoRef);
const setStatus = usePlayerStore((state) => state.setStatus);
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
const _setSourceId = usePlayerStore((state) => state.setSourceId);
const setData = usePlayerStore((state) => state.setData);
const setSeasonData = usePlayerStore((state) => state.setSeasonData);
const presentFullscreenPlayer = usePlayerStore(
(state) => state.presentFullscreenPlayer,
);
const dismissFullscreenPlayer = usePlayerStore(
(state) => state.dismissFullscreenPlayer,
);
const updateResizeMode = (newMode: ResizeMode) => {
setResizeMode(newMode);
};
const pinchGesture = Gesture.Pinch().onUpdate((e) => {
scale.value = e.scale;
if (scale.value > 1 && resizeMode !== ResizeMode.COVER) {
runOnJS(updateResizeMode)(ResizeMode.COVER);
} else if (scale.value <= 1 && resizeMode !== ResizeMode.CONTAIN) {
runOnJS(updateResizeMode)(ResizeMode.CONTAIN);
}
});
const togglePlayback = () => {
setShouldPlay(!shouldPlay);
};
const doubleTapGesture = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => {
runOnJS(togglePlayback)();
});
const screenHalfWidth = Dimensions.get("window").width / 2;
const panGesture = Gesture.Pan()
.onUpdate((event) => {
const divisor = 5000;
const panIsInHeaderOrFooter = event.y < 100 || event.y > 400;
if (panIsInHeaderOrFooter) return;
const directionMultiplier = event.velocityY < 0 ? 1 : -1;
if (event.x > screenHalfWidth) {
const change =
directionMultiplier * Math.abs(event.velocityY / divisor);
const newVolume = Math.max(
0,
Math.min(1, currentVolume.value + change),
);
runOnJS(handleVolumeChange)(newVolume);
} else {
const change =
directionMultiplier * Math.abs(event.velocityY / divisor);
const newBrightness = Math.max(
0,
Math.min(1, brightness.value + change),
);
brightness.value = newBrightness;
runOnJS(handleBrightnessChange)(newBrightness);
}
})
.onEnd(() => {
runOnJS(setShowVolumeOverlay)(false);
runOnJS(setShowBrightnessOverlay)(false);
});
const composedGesture = Gesture.Race(
panGesture,
pinchGesture,
doubleTapGesture,
);
useEffect(() => {
const initializePlayer = async () => {
if (!data) {
await dismissFullscreenPlayer();
return router.push("/(tabs)");
}
StatusBar.setStatusBarHidden(true);
if (Platform.OS === "android") {
await NavigationBar.setVisibilityAsync("hidden");
}
setData(data.item);
setIsLoading(true);
const { item, stream, media } = data;
if (media.type === "show") {
const seasonData = await fetchSeasonDetails(
media.tmdbId,
media.season.number,
);
if (seasonData) {
setSeasonData(seasonData);
}
}
setStream(stream);
setHeaderData({
title: item.title,
year: item.year,
season: media.type === "show" ? media.season.number : undefined,
episode: media.type === "show" ? media.episode.number : undefined,
});
let highestQuality;
let url;
let _tracks: HLSTracks | null;
switch (stream.type) {
case "file":
highestQuality = findHighestQuality(stream);
url = highestQuality ? stream.qualities[highestQuality]?.url : null;
return url ?? null;
case "hls":
url = stream.playlist;
_tracks = await extractTracksFromHLS(url, {
...stream.preferredHeaders,
...stream.headers,
});
}
setVideoSrc({
uri: url,
headers: {
...stream.preferredHeaders,
...stream.headers,
},
});
setIsLoading(false);
};
setIsLoading(true);
void presentFullscreenPlayer();
void initializePlayer();
return () => {
void dismissFullscreenPlayer();
StatusBar.setStatusBarHidden(false);
if (Platform.OS === "android") {
void NavigationBar.setVisibilityAsync("visible");
}
};
}, [
data,
dismissFullscreenPlayer,
presentFullscreenPlayer,
router,
setData,
setSeasonData,
setStream,
]);
const onVideoLoadStart = () => {
setIsLoading(true);
};
const onReadyForDisplay = () => {
setIsLoading(false);
};
return (
<GestureDetector gesture={composedGesture}>
<View className="flex-1 items-center justify-center bg-black">
<Video
ref={setVideoRef}
source={videoSrc}
shouldPlay={shouldPlay}
resizeMode={resizeMode}
volume={currentVolume.value}
onLoadStart={onVideoLoadStart}
onReadyForDisplay={onReadyForDisplay}
onPlaybackStatusUpdate={setStatus}
style={[
styles.video,
{
...(!isIdle && {
opacity: 0.7,
}),
},
]}
onTouchStart={() => setIsIdle(!isIdle)}
/>
{isLoading && <ActivityIndicator size="large" color="#0000ff" />}
{!isLoading && headerData && (
<ControlsOverlay headerData={headerData} />
)}
{showVolumeOverlay && (
<View className="absolute bottom-12 self-center rounded-xl bg-black p-3 opacity-50">
<Text className="font-bold">Volume: {debouncedVolume}</Text>
</View>
)}
{showBrightnessOverlay && (
<View className="absolute bottom-12 self-center rounded-xl bg-black p-3 opacity-50">
<Text className="font-bold">Brightness: {debouncedBrightness}</Text>
</View>
)}
<CaptionRenderer />
</View>
</GestureDetector>
);
};
// interface Caption {
// type: "srt" | "vtt";
// id: string;
// url: string;
// hasCorsRestrictions: boolean;
// language: string;
// }
const styles = StyleSheet.create({
video: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
});

View File

@@ -1,189 +1,185 @@
import { useEffect, useState } from "react"; // import { useEffect, useState } from "react";
import { Text } from "react-native"; // import { Text } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router"; // import { useLocalSearchParams, useRouter } from "expo-router";
import type { RunnerEvent } from "@movie-web/provider-utils"; // import type { RunnerEvent } from "@movie-web/provider-utils";
import { // import {
getVideoStream, // getVideoStream,
transformSearchResultToScrapeMedia, // transformSearchResultToScrapeMedia,
} from "@movie-web/provider-utils"; // } from "@movie-web/provider-utils";
import { fetchMediaDetails } from "@movie-web/tmdb"; // import { fetchMediaDetails } from "@movie-web/tmdb";
import type { VideoPlayerData } from "."; // import type { ItemData } from "~/components/item/item";
import type { ItemData } from "~/components/item/item"; // import ScreenLayout from "~/components/layout/ScreenLayout";
import ScreenLayout from "~/components/layout/ScreenLayout";
interface Event { // interface Event {
originalEvent: RunnerEvent; // originalEvent: RunnerEvent;
formattedMessage: string; // formattedMessage: string;
style: object; // style: object;
} // }
export default function LoadingScreenWrapper() { // export default function LoadingScreenWrapper() {
const params = useLocalSearchParams(); // const params = useLocalSearchParams();
const sourceId = params.sourceID as string | undefined; // const sourceId = params.sourceID as string | undefined;
const data = params.data // const data = params.data
? (JSON.parse(params.data as string) as ItemData) // ? (JSON.parse(params.data as string) as ItemData)
: null; // : null;
const seasonData = params.seasonData // const seasonData = params.seasonData
? (JSON.parse(params.seasonData as string) as { // ? (JSON.parse(params.seasonData as string) as {
season: number; // season: number;
episode: number; // episode: number;
}) // })
: null; // : null;
return ( // return (
<LoadingScreen sourceId={sourceId} data={data} seasonData={seasonData} /> // <LoadingScreen sourceId={sourceId} data={data} seasonData={seasonData} />
); // );
} // }
function LoadingScreen({ // function LoadingScreen({
sourceId, // sourceId,
data, // data,
seasonData, // seasonData,
}: { // }: {
sourceId: string | undefined; // sourceId: string | undefined;
data: ItemData | null; // data: ItemData | null;
seasonData: { season: number; episode: number } | null; // seasonData: { season: number; episode: number } | null;
}) { // }) {
const router = useRouter(); // const router = useRouter();
const [eventLog, setEventLog] = useState<Event[]>([]); // const [eventLog, setEventLog] = useState<Event[]>([]);
const handleEvent = (event: RunnerEvent) => { // const handleEvent = (event: RunnerEvent) => {
const { message, style } = formatEvent(event); // const { message, style } = formatEvent(event);
const formattedEvent: Event = { // const formattedEvent: Event = {
originalEvent: event, // originalEvent: event,
formattedMessage: message, // formattedMessage: message,
style: style, // style: style,
}; // };
setEventLog((prevLog) => [...prevLog, formattedEvent]); // setEventLog((prevLog) => [...prevLog, formattedEvent]);
}; // };
useEffect(() => { // useEffect(() => {
const fetchVideo = async () => { // const fetchVideo = async () => {
if (!data) return null; // if (!data) return null;
const { id, type } = data; // const { id, type } = data;
const media = await fetchMediaDetails(id, type).catch(() => null); // const media = await fetchMediaDetails(id, type).catch(() => null);
if (!media) return null; // if (!media) return null;
const { result } = media; // const { result } = media;
let season: number | undefined; // defaults to 1 when undefined // let season: number | undefined; // defaults to 1 when undefined
let episode: number | undefined; // let episode: number | undefined;
if (type === "tv") { // if (type === "tv") {
season = seasonData?.season ?? undefined; // season = seasonData?.season ?? undefined;
episode = seasonData?.episode ?? undefined; // episode = seasonData?.episode ?? undefined;
} // }
const scrapeMedia = transformSearchResultToScrapeMedia( // const scrapeMedia = transformSearchResultToScrapeMedia(
type, // type,
result, // result,
season, // season,
episode, // episode,
); // );
const stream = await getVideoStream({ // const stream = await getVideoStream({
sourceId, // media: scrapeMedia,
media: scrapeMedia, // onEvent: handleEvent,
onEvent: handleEvent, // }).catch(() => null);
}).catch(() => null); // if (!stream) return null;
if (!stream) return null;
return { stream, scrapeMedia }; // return { stream, scrapeMedia };
}; // };
const initialize = async () => { // const initialize = async () => {
const video = await fetchVideo(); // const video = await fetchVideo();
if (!video || !data) { // if (!video || !data) {
return router.back(); // return router.back();
} // }
const videoPlayerData: VideoPlayerData = { // router.replace({
item: data, // pathname: "/videoPlayer",
stream: video.stream, // params: {
media: video.scrapeMedia, // data: JSON.stringify({
}; // item: data,
// }),
// },
// });
// };
router.replace({ // void initialize();
pathname: "/videoPlayer", // }, [data, router, seasonData?.episode, seasonData?.season, sourceId]);
params: { data: JSON.stringify(videoPlayerData) },
});
};
void initialize(); // return (
}, [data, router, seasonData?.episode, seasonData?.season, sourceId]); // <ScreenLayout
// title="Checking sources"
// subtitle="Fetching sources for the requested content."
// >
// {eventLog.map((event, index) => (
// <Text key={index} style={{ ...event.style, marginVertical: 5 }}>
// {event.formattedMessage}
// </Text>
// ))}
// </ScreenLayout>
// );
// }
return ( // function formatEvent(event: RunnerEvent): { message: string; style: object } {
<ScreenLayout // let message = "";
title="Checking sources" // let style = {};
subtitle="Fetching sources for the requested content."
>
{eventLog.map((event, index) => (
<Text key={index} style={{ ...event.style, marginVertical: 5 }}>
{event.formattedMessage}
</Text>
))}
</ScreenLayout>
);
}
function formatEvent(event: RunnerEvent): { message: string; style: object } { // if (typeof event === "string") {
let message = ""; // message = `🚀 Start: ID - ${event}`;
let style = {}; // style = { color: "lime" };
// } else if (typeof event === "object" && event !== null) {
// if ("percentage" in event) {
// const evt = event;
// const statusMessage =
// evt.status === "success"
// ? "✅ Completed"
// : evt.status === "failure"
// ? "❌ Failed - " + (evt.reason ?? "Unknown Error")
// : evt.status === "notfound"
// ? "🔍 Not Found"
// : evt.status === "pending"
// ? "⏳ In Progress"
// : "❓ Unknown Status";
if (typeof event === "string") { // message = `Update: ${evt.percentage}% - Status: ${statusMessage}`;
message = `🚀 Start: ID - ${event}`; // let color = "";
style = { color: "lime" }; // switch (evt.status) {
} else if (typeof event === "object" && event !== null) { // case "success":
if ("percentage" in event) { // color = "green";
const evt = event; // break;
const statusMessage = // case "failure":
evt.status === "success" // color = "red";
? "✅ Completed" // break;
: evt.status === "failure" // case "notfound":
? "❌ Failed - " + (evt.reason ?? "Unknown Error") // color = "blue";
: evt.status === "notfound" // break;
? "🔍 Not Found" // case "pending":
: evt.status === "pending" // color = "yellow";
? "⏳ In Progress" // break;
: "❓ Unknown Status"; // default:
// color = "grey";
// break;
// }
// style = { color };
// } else if ("sourceIds" in event) {
// const evt = event;
// message = `🔍 Initialization: Source IDs - ${evt.sourceIds.join(" ")}`;
// style = { color: "skyblue" };
// } else if ("sourceId" in event) {
// const evt = event;
// const embedsInfo = evt.embeds
// .map((embed) => `ID: ${embed.id}, Scraper: ${embed.embedScraperId}`)
// .join("; ");
message = `Update: ${evt.percentage}% - Status: ${statusMessage}`; // message = `🔗 Discovered Embeds: Source ID - ${evt.sourceId} [${embedsInfo}]`;
let color = ""; // style = { color: "orange" };
switch (evt.status) { // }
case "success": // } else {
color = "green"; // message = JSON.stringify(event);
break; // style = { color: "grey" };
case "failure": // }
color = "red";
break;
case "notfound":
color = "blue";
break;
case "pending":
color = "yellow";
break;
default:
color = "grey";
break;
}
style = { color };
} else if ("sourceIds" in event) {
const evt = event;
message = `🔍 Initialization: Source IDs - ${evt.sourceIds.join(" ")}`;
style = { color: "skyblue" };
} else if ("sourceId" in event) {
const evt = event;
const embedsInfo = evt.embeds
.map((embed) => `ID: ${embed.id}, Scraper: ${embed.embedScraperId}`)
.join("; ");
message = `🔗 Discovered Embeds: Source ID - ${evt.sourceId} [${embedsInfo}]`; // return { message, style };
style = { color: "orange" }; // }
}
} else {
message = JSON.stringify(event);
style = { color: "grey" };
}
return { message, style };
}

View File

@@ -5,6 +5,7 @@ import ContextMenu from "react-native-context-menu-view";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { Text } from "~/components/ui/Text"; import { Text } from "~/components/ui/Text";
import { usePlayerStore } from "~/stores/player/store";
export interface ItemData { export interface ItemData {
id: string; id: string;
@@ -15,13 +16,15 @@ export interface ItemData {
} }
export default function Item({ data }: { data: ItemData }) { export default function Item({ data }: { data: ItemData }) {
const resetVideo = usePlayerStore((state) => state.resetVideo);
const router = useRouter(); const router = useRouter();
const { title, type, year, posterUrl } = data; const { title, type, year, posterUrl } = data;
const handlePress = () => { const handlePress = () => {
resetVideo();
Keyboard.dismiss(); Keyboard.dismiss();
router.push({ router.push({
pathname: "/videoPlayer/loading", pathname: "/videoPlayer",
params: { data: JSON.stringify(data) }, params: { data: JSON.stringify(data) },
}); });
}; };

View File

@@ -2,19 +2,19 @@ import { Keyboard } from "react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { usePlayerStore } from "~/stores/player/store"; import { usePlayer } from "~/hooks/player/usePlayer";
export const BackButton = ({ export const BackButton = ({
className, className,
}: Partial<React.ComponentProps<typeof Ionicons>>) => { }: Partial<React.ComponentProps<typeof Ionicons>>) => {
const unlockOrientation = usePlayerStore((state) => state.unlockOrientation); const { dismissFullscreenPlayer } = usePlayer();
const router = useRouter(); const router = useRouter();
return ( return (
<Ionicons <Ionicons
name="arrow-back" name="arrow-back"
onPress={() => { onPress={() => {
unlockOrientation() dismissFullscreenPlayer()
.then(() => { .then(() => {
router.back(); router.back();
return setTimeout(() => { return setTimeout(() => {

View File

@@ -6,7 +6,7 @@ import { Text } from "../ui/Text";
import { CaptionsSelector } from "./CaptionsSelector"; import { CaptionsSelector } from "./CaptionsSelector";
import { Controls } from "./Controls"; import { Controls } from "./Controls";
import { ProgressBar } from "./ProgressBar"; import { ProgressBar } from "./ProgressBar";
import { SeasonEpisodeSelector } from "./SeasonEpisodeSelector"; import { SeasonSelector } from "./SeasonEpisodeSelector";
import { SourceSelector } from "./SourceSelector"; import { SourceSelector } from "./SourceSelector";
import { mapMillisecondsToTime } from "./utils"; import { mapMillisecondsToTime } from "./utils";
@@ -36,32 +36,28 @@ export const BottomControls = () => {
if (status?.isLoaded) { if (status?.isLoaded) {
return ( return (
<Controls> <View className="flex h-32 w-full flex-col items-center justify-center p-6">
<View className="flex h-40 w-full flex-col items-center justify-center p-6"> <Controls>
<View className="w-full"> <View className="flex w-full flex-row items-center">
<View className="flex flex-row items-center"> <Text className="font-bold">{getCurrentTime()}</Text>
<Text className="font-bold">{getCurrentTime()}</Text> <Text className="mx-1 font-bold">/</Text>
<Text className="mx-1 font-bold">/</Text> <TouchableOpacity onPress={toggleTimeDisplay}>
<TouchableOpacity onPress={toggleTimeDisplay}> <Text className="font-bold">
<Text className="font-bold"> {showRemaining
{showRemaining ? getRemainingTime()
? getRemainingTime() : mapMillisecondsToTime(status.durationMillis ?? 0)}
: mapMillisecondsToTime(status.durationMillis ?? 0)} </Text>
</Text> </TouchableOpacity>
</TouchableOpacity>
</View>
<View>
<ProgressBar />
</View>
<View className="flex w-full flex-row items-center justify-start">
<SeasonEpisodeSelector />
<CaptionsSelector />
<SourceSelector />
</View>
</View> </View>
<ProgressBar />
</Controls>
<View className="flex w-full flex-row items-center justify-center gap-4 pb-10">
<SeasonSelector />
<CaptionsSelector />
<SourceSelector />
</View> </View>
</Controls> </View>
); );
} }
}; };

View File

@@ -69,28 +69,13 @@ export const CaptionRenderer = () => {
[selectedCaption, delay, status], [selectedCaption, delay, status],
); );
console.log(visibleCaptions);
if (!status?.isLoaded || !selectedCaption || !visibleCaptions?.length) if (!status?.isLoaded || !selectedCaption || !visibleCaptions?.length)
return null; return null;
return ( return (
// https://github.com/marklawlor/nativewind/issues/790
<Animated.View <Animated.View
// className="rounded px-4 py-1 text-center leading-normal [text-shadow:0_2px_4px_rgba(0,0,0,0.5)]" className="absolute bottom-24 rounded bg-black/50 px-4 py-1 text-center leading-normal"
style={[ style={animatedStyles}
{
position: "absolute",
backgroundColor: "rgba(0, 0, 0, 0.5)",
paddingLeft: 16,
paddingRight: 16,
paddingTop: 4,
paddingBottom: 4,
borderRadius: 10,
bottom: 100,
},
animatedStyles,
]}
> >
{visibleCaptions?.map((caption) => ( {visibleCaptions?.map((caption) => (
<View key={caption.index}> <View key={caption.index}>

View File

@@ -13,6 +13,7 @@ import { useCaptionsStore } from "~/stores/captions";
import { usePlayerStore } from "~/stores/player/store"; import { usePlayerStore } from "~/stores/player/store";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { Text } from "../ui/Text"; import { Text } from "../ui/Text";
import { Controls } from "./Controls";
const parseCaption = async ( const parseCaption = async (
caption: Stream["captions"][0], caption: Stream["captions"][0],
@@ -25,7 +26,9 @@ const parseCaption = async (
}; };
export const CaptionsSelector = () => { export const CaptionsSelector = () => {
const captions = usePlayerStore((state) => state.interface.stream?.captions); const captions = usePlayerStore(
(state) => state.interface.currentStream?.captions,
);
const setSelectedCaption = useCaptionsStore( const setSelectedCaption = useCaptionsStore(
(state) => state.setSelectedCaption, (state) => state.setSelectedCaption,
); );
@@ -46,18 +49,20 @@ export const CaptionsSelector = () => {
return ( return (
<View className="max-w-36 flex-1"> <View className="max-w-36 flex-1">
<Button <Controls>
title="Subtitles" <Button
variant="outline" title="Subtitles"
onPress={on} variant="outline"
iconLeft={ onPress={on}
<MaterialCommunityIcons iconLeft={
name="subtitles" <MaterialCommunityIcons
size={24} name="subtitles"
color={colors.primary[300]} size={24}
/> color={colors.primary[300]}
} />
/> }
/>
</Controls>
<Modal <Modal
isVisible={isTrue} isVisible={isTrue}

View File

@@ -1,18 +1,13 @@
import { View } from "react-native"; import { View } from "react-native";
import type { HeaderData } from "./Header";
import { BottomControls } from "./BottomControls"; import { BottomControls } from "./BottomControls";
import { Header } from "./Header"; import { Header } from "./Header";
import { MiddleControls } from "./MiddleControls"; import { MiddleControls } from "./MiddleControls";
interface ControlsOverlayProps { export const ControlsOverlay = () => {
headerData: HeaderData;
}
export const ControlsOverlay = ({ headerData }: ControlsOverlayProps) => {
return ( return (
<View className="absolute left-0 top-0 flex h-full w-full flex-1 flex-col justify-between"> <View className="flex w-full flex-1 flex-col justify-between">
<Header data={headerData} /> <Header />
<MiddleControls /> <MiddleControls />
<BottomControls /> <BottomControls />
</View> </View>

View File

@@ -6,30 +6,28 @@ import { Text } from "../ui/Text";
import { BackButton } from "./BackButton"; import { BackButton } from "./BackButton";
import { Controls } from "./Controls"; import { Controls } from "./Controls";
export interface HeaderData { const mapSeasonAndEpisodeNumberToText = (season: number, episode: number) => {
title: string; return `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`;
year: number; };
season?: number;
episode?: number;
}
interface HeaderProps { export const Header = () => {
data: HeaderData;
}
export const Header = ({ data }: HeaderProps) => {
const isIdle = usePlayerStore((state) => state.interface.isIdle); const isIdle = usePlayerStore((state) => state.interface.isIdle);
const meta = usePlayerStore((state) => state.meta);
if (!isIdle) { if (!isIdle && meta) {
return ( return (
<View className="z-50 flex h-16 w-full flex-row justify-between px-6 pt-6"> <View className="z-50 flex h-16 w-full flex-row justify-between px-6 pt-6">
<Controls> <Controls>
<BackButton className="w-36" /> <BackButton className="w-36" />
</Controls> </Controls>
<Text className="font-bold"> <Text className="font-bold">
{data.season !== undefined && data.episode !== undefined {meta.title} ({meta.releaseYear}){" "}
? `${data.title} (${data.year}) S${data.season.toString().padStart(2, "0")}E${data.episode.toString().padStart(2, "0")}` {meta.season !== undefined && meta.episode !== undefined
: `${data.title} (${data.year})`} ? mapSeasonAndEpisodeNumberToText(
meta.season.number,
meta.episode.number,
)
: ""}
</Text> </Text>
<View className="flex h-12 w-36 flex-row items-center justify-center gap-2 space-x-2 rounded-full bg-secondary-300 px-4 py-2 opacity-80"> <View className="flex h-12 w-36 flex-row items-center justify-center gap-2 space-x-2 rounded-full bg-secondary-300 px-4 py-2 opacity-80">
<Image source={Icon} className="h-6 w-6" /> <Image source={Icon} className="h-6 w-6" />

View File

@@ -21,7 +21,7 @@ export const ProgressBar = () => {
if (status?.isLoaded) { if (status?.isLoaded) {
return ( return (
<TouchableOpacity <TouchableOpacity
className="flex flex-1 items-center justify-center pb-12 pt-6" className="flex flex-1 items-center justify-center pb-8 pt-6"
onPress={() => setIsIdle(false)} onPress={() => setIsIdle(false)}
> >
<VideoSlider onSlidingComplete={updateProgress} /> <VideoSlider onSlidingComplete={updateProgress} />

View File

@@ -0,0 +1,115 @@
import { useEffect } from "react";
import { ActivityIndicator, View } from "react-native";
import { useRouter } from "expo-router";
import {
extractTracksFromHLS,
getVideoStream,
transformSearchResultToScrapeMedia,
} from "@movie-web/provider-utils";
import { fetchMediaDetails, fetchSeasonDetails } from "@movie-web/tmdb";
import type { ItemData } from "../item/item";
import { usePlayerStore } from "~/stores/player/store";
import { Text } from "../ui/Text";
interface ScraperProcessProps {
data: ItemData;
}
export const ScraperProcess = ({ data }: ScraperProcessProps) => {
const router = useRouter();
const meta = usePlayerStore((state) => state.meta);
const setStream = usePlayerStore((state) => state.setCurrentStream);
const setSeasonData = usePlayerStore((state) => state.setSeasonData);
const setHlsTracks = usePlayerStore((state) => state.setHlsTracks);
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
const setSourceId = usePlayerStore((state) => state.setSourceId);
const setMeta = usePlayerStore((state) => state.setMeta);
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,
);
}
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,
});
if (!streamResult) return router.back();
setStream(streamResult.stream);
if (streamResult.stream.type === "hls") {
const tracks = await extractTracksFromHLS(
streamResult.stream.playlist,
{
...streamResult.stream.preferredHeaders,
...streamResult.stream.headers,
},
);
if (tracks) setHlsTracks(tracks);
}
setPlayerStatus("ready");
setSourceId(streamResult.sourceId);
};
void fetchData();
}, [
data,
router,
setHlsTracks,
setSeasonData,
setStream,
setPlayerStatus,
setSourceId,
setMeta,
meta?.season?.number,
meta?.episode?.number,
]);
return (
<View className="flex-1">
<View className="flex-1 items-center justify-center bg-black">
<View className="flex flex-col items-center">
<Text className="mb-4 text-2xl text-white">Checking sources</Text>
<ActivityIndicator size="large" color="#0000ff" />
</View>
</View>
</View>
);
};

View File

@@ -1,65 +1,182 @@
import { ScrollView, View } from "react-native"; import { useState } from "react";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import Modal from "react-native-modal"; import Modal from "react-native-modal";
import { useRouter } from "expo-router"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { useQuery } from "@tanstack/react-query";
import colors from "@movie-web/tailwind-config/colors"; import colors from "@movie-web/tailwind-config/colors";
import { fetchMediaDetails, fetchSeasonDetails } from "@movie-web/tmdb";
import { useBoolean } from "~/hooks/useBoolean"; import { useBoolean } from "~/hooks/useBoolean";
import { usePlayerStore } from "~/stores/player/store"; import { usePlayerStore } from "~/stores/player/store";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { Divider } from "../ui/Divider";
import { Text } from "../ui/Text"; import { Text } from "../ui/Text";
import { Controls } from "./Controls";
const EpisodeSelector = ({
seasonNumber,
setSelectedSeason,
closeModal,
}: {
seasonNumber: number;
setSelectedSeason: (season: number | null) => void;
closeModal: () => void;
}) => {
const meta = usePlayerStore((state) => state.meta);
const setMeta = usePlayerStore((state) => state.setMeta);
const { data, isLoading } = useQuery({
queryKey: ["seasonEpisodes", meta!.tmdbId, seasonNumber],
queryFn: async () => {
return fetchSeasonDetails(meta!.tmdbId, seasonNumber);
},
enabled: meta !== null,
});
if (!meta) return null;
return (
<>
{isLoading && (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={colors.primary[300]} />
</View>
)}
{data && (
<ScrollView
className="flex-1 flex-col bg-gray-900"
contentContainerStyle={{
padding: 10,
}}
>
<View className="flex-row items-center gap-4 p-2">
<Ionicons
name="arrow-back"
size={20}
color="white"
onPress={() => setSelectedSeason(null)}
/>
<Text className="text-center font-bold">
Season {data.season_number}
</Text>
</View>
<Divider />
{data.episodes.map((episode) => (
<TouchableOpacity
key={episode.id}
className="p-3"
onPress={() => {
setMeta({
...meta,
episode: {
number: episode.episode_number,
tmdbId: episode.id.toString(),
},
});
closeModal();
}}
>
<Text>
E{episode.episode_number} {episode.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
)}
</>
);
};
export const SeasonSelector = () => {
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
const meta = usePlayerStore((state) => state.meta);
export const SeasonEpisodeSelector = () => {
const data = usePlayerStore((state) => state.interface.data);
const seasonData = usePlayerStore((state) => state.interface.seasonData);
const { isTrue, on, off } = useBoolean(); const { isTrue, on, off } = useBoolean();
const router = useRouter();
return data?.type === "movie" || !seasonData ? null : ( const { data, isLoading } = useQuery({
queryKey: ["seasons", meta!.tmdbId],
queryFn: async () => {
return fetchMediaDetails(meta!.tmdbId, "tv");
},
enabled: meta !== null,
});
if (meta?.type !== "show") return null;
return (
<View className="max-w-36 flex-1"> <View className="max-w-36 flex-1">
<Button <Controls>
title="Episode" <Button
variant="outline" title="Episode"
onPress={on} variant="outline"
iconLeft={ onPress={on}
<MaterialCommunityIcons iconLeft={
name="audio-video" <MaterialCommunityIcons
size={24} name="audio-video"
color={colors.primary[300]} size={24}
/> color={colors.primary[300]}
} />
/> }
/>
</Controls>
<Modal <Modal
isVisible={isTrue} isVisible={isTrue}
onBackdropPress={off} onBackdropPress={off}
supportedOrientations={["portrait", "landscape"]} supportedOrientations={["portrait", "landscape"]}
style={{
width: "35%",
justifyContent: "center",
alignSelf: "center",
}}
> >
<ScrollView className="flex-1 bg-gray-900"> {selectedSeason === null && (
<Text className="text-center font-bold">Select episode</Text> <>
{seasonData.episodes.map((episode) => ( {isLoading && (
<Button <View className="flex-1 items-center justify-center">
key={episode.id} <ActivityIndicator size="large" color={colors.primary[300]} />
title={episode.name} </View>
onPress={() => { )}
off(); {data && (
router.push({ <ScrollView
// replace throws exception className="flex-1 flex-col bg-gray-900"
pathname: "/videoPlayer/loading", contentContainerStyle={{
params: { padding: 10,
data: JSON.stringify(data), }}
seasonData: JSON.stringify({ >
season: seasonData.season_number, <Text className="text-center font-bold">
episode: episode.episode_number, {data.result.name}
}), </Text>
}, <Divider />
}); {data.result.seasons.map((season) => (
}} <TouchableOpacity
className="max-w-16" key={season.season_number}
/> className="m-1 flex flex-row items-center p-2"
))} onPress={() => setSelectedSeason(season.season_number)}
</ScrollView> >
<Text className="flex-grow">
Season {season.season_number}
</Text>
<Ionicons name="chevron-forward" size={24} color="white" />
</TouchableOpacity>
))}
</ScrollView>
)}
</>
)}
{selectedSeason !== null && (
<EpisodeSelector
seasonNumber={selectedSeason}
setSelectedSeason={setSelectedSeason}
closeModal={off}
/>
)}
</Modal> </Modal>
</View> </View>
); );

View File

@@ -1,58 +1,193 @@
import { ScrollView, View } from "react-native"; import { useCallback, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
import Modal from "react-native-modal"; import Modal from "react-native-modal";
import { useRouter } from "expo-router"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { getBuiltinSources } from "@movie-web/provider-utils"; import { getBuiltinSources, providers } from "@movie-web/provider-utils";
import colors from "@movie-web/tailwind-config/colors"; import colors from "@movie-web/tailwind-config/colors";
import {
useEmbedScrape,
useSourceScrape,
} from "~/hooks/player/useSourceScrape";
import { useBoolean } from "~/hooks/useBoolean"; import { useBoolean } from "~/hooks/useBoolean";
import { usePlayerStore } from "~/stores/player/store"; import { usePlayerStore } from "~/stores/player/store";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { Text } from "../ui/Text"; import { Text } from "../ui/Text";
import { Controls } from "./Controls";
export const SourceSelector = () => { const SourceItem = ({
const data = usePlayerStore((state) => state.interface.data); name,
const { isTrue, on, off } = useBoolean(); id,
const router = useRouter(); active,
embed,
onPress,
closeModal,
}: {
name: string;
id: string;
active?: boolean;
embed?: { url: string; embedId: string };
onPress?: (id: string) => void;
closeModal?: () => void;
}) => {
const { mutate, isPending, isError } = useEmbedScrape(closeModal);
return ( return (
<View className="max-w-36 flex-1"> <Pressable
<Button className="flex w-full flex-row justify-between p-3"
title="Source" onPress={() => {
variant="outline" if (onPress) {
onPress={on} onPress(id);
iconLeft={ return;
<MaterialCommunityIcons
name="video"
size={24}
color={colors.primary[300]}
/>
} }
/> if (embed) {
mutate({
url: embed.url,
embedId: embed.embedId,
sourceId: id,
});
}
}}
>
<Text className="font-bold">{name}</Text>
{active && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={colors.primary[300]}
/>
)}
{isError && (
<MaterialCommunityIcons
name="alert-circle"
size={24}
color={colors.red[500]}
/>
)}
{isPending && <ActivityIndicator size="small" color="#0000ff" />}
</Pressable>
);
};
const EmbedsPart = ({
sourceId,
setCurrentScreen,
closeModal,
}: {
sourceId: string;
setCurrentScreen: (screen: "source" | "embed") => void;
closeModal: () => void;
}) => {
const { data, isPending, error } = useSourceScrape(sourceId, closeModal);
return (
<View className="flex w-full flex-col gap-4 p-3">
<View className="flex-row items-center gap-4">
<Ionicons
name="arrow-back"
size={30}
color="white"
onPress={() => setCurrentScreen("source")}
/>
<Text className="text-xl font-bold">Embeds</Text>
</View>
{isPending && <ActivityIndicator size="small" color="#0000ff" />}
{error && <Text>{error.message}</Text>}
{data && data?.length > 1 && (
<View className="flex w-full flex-col p-3">
{data.map((embed) => {
const metaData = providers.getMetadata(embed.embedId)!;
return (
<SourceItem
key={embed.embedId}
name={metaData.name}
id={embed.embedId}
embed={embed}
closeModal={closeModal}
/>
);
})}
</View>
)}
</View>
);
};
export const SourceSelector = () => {
const [currentScreen, setCurrentScreen] = useState<"source" | "embed">(
"source",
);
const sourceId = usePlayerStore((state) => state.interface.sourceId);
const setSourceId = usePlayerStore((state) => state.setSourceId);
const { isTrue, on, off } = useBoolean();
const isActive = useCallback(
(id: string) => {
return sourceId === id;
},
[sourceId],
);
return (
<View className="max-w-36">
<Controls>
<Button
title="Source"
variant="outline"
onPress={on}
iconLeft={
<MaterialCommunityIcons
name="video"
size={24}
color={colors.primary[300]}
/>
}
/>
</Controls>
<Modal <Modal
isVisible={isTrue} isVisible={isTrue}
onBackdropPress={off} onBackdropPress={off}
supportedOrientations={["portrait", "landscape"]} supportedOrientations={["portrait", "landscape"]}
style={{
width: "35%",
justifyContent: "center",
alignSelf: "center",
}}
> >
<ScrollView className="flex-1 bg-gray-900"> <ScrollView
<Text className="text-center font-bold">Select source</Text> className="w-full flex-1 bg-gray-900"
{getBuiltinSources().map((source) => ( contentContainerStyle={{
<Button padding: 10,
key={source.id} }}
title={source.name} >
onPress={() => { {currentScreen === "source" && (
off(); <>
router.push({ {getBuiltinSources()
// replace throws exception .sort((a, b) => b.rank - a.rank)
pathname: "/videoPlayer/loading", .map((source) => (
params: { sourceID: source.id, data: JSON.stringify(data) }, <SourceItem
}); key={source.id}
}} name={source.name}
className="max-w-16" id={source.id}
active={isActive(source.id)}
onPress={() => {
setSourceId(source.id);
setCurrentScreen("embed");
}}
/>
))}
</>
)}
{currentScreen === "embed" && (
<EmbedsPart
sourceId={sourceId!}
setCurrentScreen={setCurrentScreen}
closeModal={off}
/> />
))} )}
</ScrollView> </ScrollView>
</Modal> </Modal>
</View> </View>

View File

@@ -0,0 +1,222 @@
import type { AVPlaybackSource } from "expo-av";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
Dimensions,
Platform,
StyleSheet,
View,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { runOnJS, useSharedValue } from "react-native-reanimated";
import { ResizeMode, Video } from "expo-av";
import * as NavigationBar from "expo-navigation-bar";
import { useRouter } from "expo-router";
import * as StatusBar from "expo-status-bar";
import { findHighestQuality } from "@movie-web/provider-utils";
import { useBrightness } from "~/hooks/player/useBrightness";
import { usePlayer } from "~/hooks/player/usePlayer";
import { useVolume } from "~/hooks/player/useVolume";
import { usePlayerStore } from "~/stores/player/store";
import { Text } from "../ui/Text";
import { CaptionRenderer } from "./CaptionRenderer";
import { ControlsOverlay } from "./ControlsOverlay";
export const VideoPlayer = () => {
const {
brightness,
debouncedBrightness,
showBrightnessOverlay,
setShowBrightnessOverlay,
handleBrightnessChange,
} = useBrightness();
const {
currentVolume,
debouncedVolume,
showVolumeOverlay,
setShowVolumeOverlay,
handleVolumeChange,
} = useVolume();
const { dismissFullscreenPlayer } = usePlayer();
const [videoSrc, setVideoSrc] = useState<AVPlaybackSource>();
const [isLoading, setIsLoading] = useState(true);
const [resizeMode, setResizeMode] = useState(ResizeMode.CONTAIN);
const [shouldPlay, setShouldPlay] = useState(true);
const router = useRouter();
const scale = useSharedValue(1);
const isIdle = usePlayerStore((state) => state.interface.isIdle);
const stream = usePlayerStore((state) => state.interface.currentStream);
const setVideoRef = usePlayerStore((state) => state.setVideoRef);
const setStatus = usePlayerStore((state) => state.setStatus);
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
const updateResizeMode = (newMode: ResizeMode) => {
setResizeMode(newMode);
};
const pinchGesture = Gesture.Pinch().onUpdate((e) => {
scale.value = e.scale;
if (scale.value > 1 && resizeMode !== ResizeMode.COVER) {
runOnJS(updateResizeMode)(ResizeMode.COVER);
} else if (scale.value <= 1 && resizeMode !== ResizeMode.CONTAIN) {
runOnJS(updateResizeMode)(ResizeMode.CONTAIN);
}
});
const togglePlayback = () => {
setShouldPlay(!shouldPlay);
};
const doubleTapGesture = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => {
runOnJS(togglePlayback)();
});
const screenHalfWidth = Dimensions.get("window").width / 2;
const panGesture = Gesture.Pan()
.onUpdate((event) => {
const divisor = 5000;
const panIsInHeaderOrFooter = event.y < 100 || event.y > 400;
if (panIsInHeaderOrFooter) return;
const directionMultiplier = event.velocityY < 0 ? 1 : -1;
if (event.x > screenHalfWidth) {
const change =
directionMultiplier * Math.abs(event.velocityY / divisor);
const newVolume = Math.max(
0,
Math.min(1, currentVolume.value + change),
);
runOnJS(handleVolumeChange)(newVolume);
} else {
const change =
directionMultiplier * Math.abs(event.velocityY / divisor);
const newBrightness = Math.max(
0,
Math.min(1, brightness.value + change),
);
brightness.value = newBrightness;
runOnJS(handleBrightnessChange)(newBrightness);
}
})
.onEnd(() => {
runOnJS(setShowVolumeOverlay)(false);
runOnJS(setShowBrightnessOverlay)(false);
});
const composedGesture = Gesture.Race(
panGesture,
pinchGesture,
doubleTapGesture,
);
StatusBar.setStatusBarHidden(true);
if (Platform.OS === "android") {
void NavigationBar.setVisibilityAsync("hidden");
}
useEffect(() => {
const initializePlayer = async () => {
if (!stream) {
await dismissFullscreenPlayer();
return router.push("/(tabs)");
}
setIsLoading(true);
let url = null;
if (stream.type === "hls") {
url = stream.playlist;
}
if (stream.type === "file") {
const highestQuality = findHighestQuality(stream);
url = highestQuality ? stream.qualities[highestQuality]?.url : null;
}
if (!url) {
await dismissFullscreenPlayer();
return router.push("/(tabs)");
}
setVideoSrc({
uri: url,
headers: {
...stream.preferredHeaders,
...stream.headers,
},
});
setIsLoading(false);
};
setIsLoading(true);
void initializePlayer();
}, [dismissFullscreenPlayer, router, stream]);
const onVideoLoadStart = () => {
setIsLoading(true);
};
const onReadyForDisplay = () => {
setIsLoading(false);
};
console.log(videoSrc, isLoading);
return (
<GestureDetector gesture={composedGesture}>
<View className="flex-1 items-center justify-center bg-black">
<Video
ref={setVideoRef}
source={videoSrc}
shouldPlay={shouldPlay}
resizeMode={resizeMode}
volume={currentVolume.value}
onLoadStart={onVideoLoadStart}
onReadyForDisplay={onReadyForDisplay}
onPlaybackStatusUpdate={setStatus}
style={[
styles.video,
{
...(!isIdle && {
opacity: 0.7,
}),
},
]}
onTouchStart={() => setIsIdle(!isIdle)}
/>
{isLoading && <ActivityIndicator size="large" color="#0000ff" />}
{!isLoading && <ControlsOverlay />}
{showVolumeOverlay && (
<View className="absolute bottom-12 self-center rounded-xl bg-black p-3 opacity-50">
<Text className="font-bold">Volume: {debouncedVolume}</Text>
</View>
)}
{showBrightnessOverlay && (
<View className="absolute bottom-12 self-center rounded-xl bg-black p-3 opacity-50">
<Text className="font-bold">Brightness: {debouncedBrightness}</Text>
</View>
)}
<CaptionRenderer />
</View>
</GestureDetector>
);
};
const styles = StyleSheet.create({
video: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
});

View File

@@ -0,0 +1,5 @@
import { View } from "react-native";
export const Divider = () => {
return <View className="mx-5 my-3 h-px border-t-0 bg-slate-600" />;
};

View File

@@ -0,0 +1,21 @@
import { useCallback } from "react";
import * as ScreenOrientation from "expo-screen-orientation";
export const usePlayer = () => {
const presentFullscreenPlayer = useCallback(async () => {
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.LANDSCAPE,
);
}, []);
const dismissFullscreenPlayer = useCallback(async () => {
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}, []);
return {
presentFullscreenPlayer,
dismissFullscreenPlayer,
} as const;
};

View File

@@ -0,0 +1,89 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getVideoStreamFromEmbed,
getVideoStreamFromSource,
} from "@movie-web/provider-utils";
import { convertMetaToScrapeMedia } from "~/stores/player/slices/video";
import { usePlayerStore } from "~/stores/player/store";
export const useEmbedScrape = (closeModal?: () => void) => {
const setCurrentStream = usePlayerStore((state) => state.setCurrentStream);
const queryClient = useQueryClient();
const mutate = useMutation({
mutationKey: ["embedScrape"],
mutationFn: async ({
url,
embedId,
}: {
url: string;
embedId: string;
sourceId: string;
}) => {
const result = await getVideoStreamFromEmbed({
url,
embedId,
});
if (result.stream) {
closeModal?.();
setCurrentStream(result.stream[0]!);
return result.stream;
}
return result.stream;
},
onSuccess: async () => {
await queryClient.resetQueries({
queryKey: ["sourceScrape"],
});
},
});
return mutate;
};
export const useSourceScrape = (
sourceId: string | null,
closeModal: () => void,
) => {
const meta = usePlayerStore((state) => state.meta);
const setCurrentStream = usePlayerStore((state) => state.setCurrentStream);
const setSourceId = usePlayerStore((state) => state.setSourceId);
const query = useQuery({
queryKey: ["sourceScrape", meta, sourceId],
queryFn: async () => {
if (!meta || !sourceId) return;
const scrapeMedia = convertMetaToScrapeMedia(meta);
const result = await getVideoStreamFromSource({
sourceId,
media: scrapeMedia,
events: {
update(evt) {
console.log(evt);
},
},
});
if (result.stream) {
closeModal();
setCurrentStream(result.stream[0]!);
setSourceId(sourceId);
return [];
}
if (result.embeds.length === 1) {
const embedResult = await getVideoStreamFromEmbed(result.embeds[0]!);
if (embedResult.stream) {
closeModal();
setCurrentStream(embedResult.stream[0]!);
setSourceId(sourceId);
return [];
}
}
return result.embeds;
},
});
return query;
};

View File

@@ -1,41 +1,47 @@
import * as ScreenOrientation from "expo-screen-orientation"; import type { HLSTracks, Stream } from "@movie-web/provider-utils";
import type { Stream } from "@movie-web/provider-utils";
import type { SeasonDetails } from "@movie-web/tmdb"; import type { SeasonDetails } from "@movie-web/tmdb";
import type { MakeSlice } from "./types"; import type { MakeSlice } from "./types";
import type { ItemData } from "~/components/item/item"; import type { ItemData } from "~/components/item/item";
export type PlayerStatus = "scraping" | "ready";
export interface InterfaceSlice { export interface InterfaceSlice {
interface: { interface: {
isIdle: boolean; isIdle: boolean;
idleTimeout: NodeJS.Timeout | null; idleTimeout: NodeJS.Timeout | null;
stream: Stream | null; currentStream: Stream | null;
availableStreams: Stream[] | null;
sourceId: string | null; sourceId: string | null;
data: ItemData | null; data: ItemData | null;
seasonData: SeasonDetails | null; seasonData: SeasonDetails | null;
selectedCaption: Stream["captions"][0] | null; selectedCaption: Stream["captions"][0] | null;
hlsTracks: HLSTracks | null;
playerStatus: PlayerStatus;
}; };
setIsIdle(state: boolean): void; setIsIdle(state: boolean): void;
setStream(stream: Stream): void; setCurrentStream(stream: Stream): void;
setAvailableStreams(streams: Stream[]): void;
setSourceId(sourceId: string): void; setSourceId(sourceId: string): void;
setData(data: ItemData): void; setData(data: ItemData): void;
setSeasonData(data: SeasonDetails): void; setSeasonData(data: SeasonDetails): void;
lockOrientation: () => Promise<void>; setHlsTracks(tracks: HLSTracks): void;
unlockOrientation: () => Promise<void>; setPlayerStatus(status: PlayerStatus): void;
presentFullscreenPlayer: () => Promise<void>; reset: () => void;
dismissFullscreenPlayer: () => Promise<void>;
} }
export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
interface: { interface: {
isIdle: true, isIdle: true,
idleTimeout: null, idleTimeout: null,
stream: null, currentStream: null,
availableStreams: null,
sourceId: null, sourceId: null,
data: null, data: null,
seasonData: null, seasonData: null,
selectedCaption: null, selectedCaption: null,
hlsTracks: null,
playerStatus: "scraping",
}, },
setIsIdle: (state) => { setIsIdle: (state) => {
set((s) => { set((s) => {
@@ -52,9 +58,14 @@ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
s.interface.isIdle = state; s.interface.isIdle = state;
}); });
}, },
setStream: (stream) => { setCurrentStream: (stream) => {
set((s) => { set((s) => {
s.interface.stream = stream; s.interface.currentStream = stream;
});
},
setAvailableStreams: (streams) => {
set((s) => {
s.interface.availableStreams = streams;
}); });
}, },
setSourceId: (sourceId: string) => { setSourceId: (sourceId: string) => {
@@ -72,20 +83,30 @@ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
s.interface.seasonData = data; s.interface.seasonData = data;
}); });
}, },
lockOrientation: async () => { setHlsTracks: (tracks) => {
await ScreenOrientation.lockAsync( set((s) => {
ScreenOrientation.OrientationLock.LANDSCAPE, s.interface.hlsTracks = tracks;
); });
}, },
unlockOrientation: async () => { setPlayerStatus: (status) => {
await ScreenOrientation.lockAsync( set((s) => {
ScreenOrientation.OrientationLock.PORTRAIT_UP, s.interface.playerStatus = status;
); });
}, },
presentFullscreenPlayer: async () => { reset: () => {
await get().lockOrientation(); set(() => ({
}, interface: {
dismissFullscreenPlayer: async () => { isIdle: true,
await get().unlockOrientation(); idleTimeout: null,
currentStream: null,
availableStreams: null,
sourceId: null,
data: null,
seasonData: null,
selectedCaption: null,
hlsTracks: null,
playerStatus: "scraping",
},
}));
}, },
}); });

View File

@@ -1,18 +1,70 @@
import type { AVPlaybackStatus, Video } from "expo-av"; import type { AVPlaybackStatus, Video } from "expo-av";
import type { ScrapeMedia } from "@movie-web/provider-utils";
import type { MakeSlice } from "./types"; import type { MakeSlice } from "./types";
export interface PlayerMetaEpisode {
number: number;
tmdbId: string;
title?: string;
}
export interface PlayerMeta {
type: "movie" | "show";
title: string;
tmdbId: string;
imdbId?: string;
releaseYear: number;
poster?: string;
episodes?: PlayerMetaEpisode[];
episode?: PlayerMetaEpisode;
season?: {
number: number;
tmdbId: string;
title?: string;
};
}
export interface VideoSlice { export interface VideoSlice {
videoRef: Video | null; videoRef: Video | null;
status: AVPlaybackStatus | null; status: AVPlaybackStatus | null;
meta: PlayerMeta | null;
setVideoRef(ref: Video | null): void; setVideoRef(ref: Video | null): void;
setStatus(status: AVPlaybackStatus | null): void; setStatus(status: AVPlaybackStatus | null): void;
setMeta(meta: PlayerMeta | null): void;
resetVideo(): void;
} }
export const convertMetaToScrapeMedia = (meta: PlayerMeta): ScrapeMedia => {
if (meta.type === "movie") {
return {
title: meta.title,
releaseYear: meta.releaseYear,
type: "movie",
tmdbId: meta.tmdbId,
imdbId: meta.imdbId,
};
}
if (meta.type === "show") {
return {
title: meta.title,
releaseYear: meta.releaseYear,
type: "show",
tmdbId: meta.tmdbId,
season: meta.season!,
episode: meta.episode!,
imdbId: meta.imdbId,
};
}
throw new Error("Invalid meta type");
};
export const createVideoSlice: MakeSlice<VideoSlice> = (set) => ({ export const createVideoSlice: MakeSlice<VideoSlice> = (set) => ({
videoRef: null, videoRef: null,
status: null, status: null,
meta: null,
setVideoRef: (ref) => { setVideoRef: (ref) => {
set({ videoRef: ref }); set({ videoRef: ref });
@@ -22,4 +74,16 @@ export const createVideoSlice: MakeSlice<VideoSlice> = (set) => ({
s.status = status; s.status = status;
}); });
}, },
setMeta: (meta) => {
set((s) => {
s.interface.playerStatus = "scraping";
s.meta = meta;
});
},
resetVideo() {
set({ videoRef: null, status: null, meta: null });
set((s) => {
s.interface.playerStatus = "scraping";
});
},
}); });

View File

@@ -26,9 +26,5 @@
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"prettier": "@movie-web/prettier-config", "prettier": "@movie-web/prettier-config",
"pnpm": { "pnpm": {}
"patchedDependencies": {
"nativewind@4.0.23": "patches/nativewind@4.0.23.patch"
}
}
} }

View File

@@ -1,9 +1,5 @@
import type { ScrapeMedia, Stream } from "@movie-web/providers";
import { getBuiltinSources } from "@movie-web/providers";
export const name = "provider-utils"; export const name = "provider-utils";
export * from "./video"; export * from "./video";
export * from "./util"; export * from "./util";
export type { Stream, ScrapeMedia }; export * from "@movie-web/providers";
export { getBuiltinSources };

View File

@@ -2,9 +2,11 @@ import type { AppendToResponse, MovieDetails, TvShowDetails } from "tmdb-ts";
import type { ScrapeMedia } from "@movie-web/providers"; import type { ScrapeMedia } from "@movie-web/providers";
export function transformSearchResultToScrapeMedia( export function transformSearchResultToScrapeMedia<T extends "tv" | "movie">(
type: "tv" | "movie", type: T,
result: TvShowDetails | MovieDetails, result: T extends "tv"
? AppendToResponse<TvShowDetails, "external_ids"[], "tvShow">
: AppendToResponse<MovieDetails, "external_ids"[], "movie">,
season?: number, season?: number,
episode?: number, episode?: number,
): ScrapeMedia { ): ScrapeMedia {

View File

@@ -4,10 +4,15 @@ import { default as toWebVTT } from "srt-webvtt";
import type { import type {
EmbedOutput, EmbedOutput,
EmbedRunnerOptions,
FileBasedStream, FileBasedStream,
FullScraperEvents,
Qualities, Qualities,
RunnerOptions, RunnerOptions,
RunOutput,
ScrapeMedia, ScrapeMedia,
SourcererOutput,
SourceRunnerOptions,
Stream, Stream,
} from "@movie-web/providers"; } from "@movie-web/providers";
import { import {
@@ -44,105 +49,173 @@ export type RunnerEvent =
| UpdateEvent | UpdateEvent
| DiscoverEmbedsEvent; | DiscoverEmbedsEvent;
export const providers = makeProviders({
fetcher: makeStandardFetcher(fetch),
target: targets.NATIVE,
consistentIpForRequests: true,
});
export async function getVideoStream({ export async function getVideoStream({
sourceId,
media, media,
forceVTT, forceVTT,
onEvent, events,
}: { }: {
sourceId?: string;
media: ScrapeMedia; media: ScrapeMedia;
forceVTT?: boolean; forceVTT?: boolean;
onEvent?: (event: RunnerEvent) => void; events?: FullScraperEvents;
}): Promise<Stream | null> { }): Promise<RunOutput | null> {
const providers = makeProviders({
fetcher: makeStandardFetcher(fetch),
target: targets.NATIVE,
consistentIpForRequests: true,
});
const options: RunnerOptions = { const options: RunnerOptions = {
media, media,
events: { events,
init: onEvent,
update: onEvent,
discoverEmbeds: onEvent,
start: onEvent,
},
}; };
let stream: Stream | null = null; const stream = await providers.runAll(options);
if (sourceId) {
onEvent && onEvent({ sourceIds: [sourceId] });
let embedOutput: EmbedOutput | undefined;
const sourceResult = await providers
.runSourceScraper({
id: sourceId,
media,
})
.catch((error: Error) => {
onEvent &&
onEvent({ id: sourceId, percentage: 0, status: "failure", error });
return undefined;
});
if (sourceResult) {
onEvent && onEvent({ id: sourceId, percentage: 50, status: "pending" });
for (const embed of sourceResult.embeds) {
const embedResult = await providers
.runEmbedScraper({
id: embed.embedId,
url: embed.url,
})
.catch(() => undefined);
if (embedResult) {
embedOutput = embedResult;
onEvent &&
onEvent({ id: embed.embedId, percentage: 100, status: "success" });
}
}
}
if (embedOutput) {
stream = embedOutput.stream[0] ?? null;
} else if (sourceResult) {
stream = sourceResult.stream?.[0] ?? null;
}
if (stream) {
onEvent && onEvent({ id: sourceId, percentage: 100, status: "success" });
} else {
onEvent && onEvent({ id: sourceId, percentage: 100, status: "notfound" });
}
} else {
stream = await providers
.runAll(options)
.then((result) => result?.stream ?? null);
}
if (!stream) return null; if (!stream) return null;
if (forceVTT) { if (forceVTT) {
if (stream.captions && stream.captions.length > 0) { const streamResult = await convertStreamCaptionsToWebVTT(stream.stream);
for (const caption of stream.captions) { return { ...stream, stream: streamResult };
if (caption.type === "srt") {
const response = await fetch(caption.url);
const srtSubtitle = await response.blob();
const vttSubtitleUrl = await toWebVTT(srtSubtitle);
caption.url = vttSubtitleUrl;
caption.type = "vtt";
}
}
}
} }
return stream; return stream;
} }
export async function getVideoStreamFromSource({
sourceId,
media,
events,
}: {
sourceId: string;
media: ScrapeMedia;
events?: SourceRunnerOptions["events"];
}): Promise<SourcererOutput> {
const sourceResult = await providers.runSourceScraper({
id: sourceId,
media,
events,
});
return sourceResult;
}
export async function getVideoStreamFromEmbed({
embedId,
url,
events,
}: {
embedId: string;
url: string;
events?: EmbedRunnerOptions["events"];
}): Promise<EmbedOutput> {
const embedResult = await providers.runEmbedScraper({
id: embedId,
url,
events,
});
return embedResult;
}
// export async function getVideoStream({
// sourceId,
// media,
// forceVTT,
// onEvent,
// }: {
// sourceId?: string;
// media: ScrapeMedia;
// forceVTT?: boolean;
// onEvent?: (event: RunnerEvent) => void;
// }): Promise<Stream | null> {
// const providers = makeProviders({
// fetcher: makeStandardFetcher(fetch),
// target: targets.NATIVE,
// consistentIpForRequests: true,
// });
// const options: RunnerOptions = {
// media,
// events: {
// init: onEvent,
// update: onEvent,
// discoverEmbeds: onEvent,
// start: onEvent,
// },
// };
// let stream: Stream | null = null;
// if (sourceId) {
// onEvent && onEvent({ sourceIds: [sourceId] });
// let embedOutput: EmbedOutput | undefined;
// const sourceResult = await providers
// .runSourceScraper({
// id: sourceId,
// media,
// events: {},
// })
// .catch((error: Error) => {
// onEvent &&
// onEvent({ id: sourceId, percentage: 0, status: "failure", error });
// return undefined;
// });
// if (sourceResult) {
// onEvent && onEvent({ id: sourceId, percentage: 50, status: "pending" });
// for (const embed of sourceResult.embeds) {
// const embedResult = await providers
// .runEmbedScraper({
// id: embed.embedId,
// url: embed.url,
// })
// .catch(() => undefined);
// if (embedResult) {
// embedOutput = embedResult;
// onEvent &&
// onEvent({ id: embed.embedId, percentage: 100, status: "success" });
// }
// }
// }
// if (embedOutput) {
// stream = embedOutput.stream[0] ?? null;
// } else if (sourceResult) {
// stream = sourceResult.stream?.[0] ?? null;
// }
// if (stream) {
// onEvent && onEvent({ id: sourceId, percentage: 100, status: "success" });
// } else {
// onEvent && onEvent({ id: sourceId, percentage: 100, status: "notfound" });
// }
// } else {
// stream = await providers
// .runAll(options)
// .then((result) => result?.stream ?? null);
// }
// if (!stream) return null;
// if (forceVTT) {
// if (stream.captions && stream.captions.length > 0) {
// for (const caption of stream.captions) {
// if (caption.type === "srt") {
// const response = await fetch(caption.url);
// const srtSubtitle = await response.blob();
// const vttSubtitleUrl = await toWebVTT(srtSubtitle);
// caption.url = vttSubtitleUrl;
// caption.type = "vtt";
// }
// }
// }
// }
// return stream;
// }
export function findHighestQuality( export function findHighestQuality(
stream: FileBasedStream, stream: FileBasedStream,
): Qualities | undefined { ): Qualities | undefined {
@@ -186,3 +259,18 @@ export async function extractTracksFromHLS(
return null; return null;
} }
} }
export async function convertStreamCaptionsToWebVTT(
stream: Stream,
): Promise<Stream> {
if (!stream.captions) return stream;
for (const caption of stream.captions) {
if (caption.type === "srt") {
const response = await fetch(caption.url);
const srt = await response.blob();
caption.url = await toWebVTT(srt);
caption.type = "vtt";
}
}
return stream;
}

View File

@@ -1,26 +1,34 @@
import type { MovieDetails, SeasonDetails, TvShowDetails } from "tmdb-ts"; import type {
AppendToResponse,
MovieDetails,
SeasonDetails,
TvShowDetails,
} from "tmdb-ts";
import { tmdb } from "./util"; import { tmdb } from "./util";
export async function fetchMediaDetails( export async function fetchMediaDetails<
id: string, T extends "movie" | "tv",
type: "movie" | "tv", R = T extends "movie"
): Promise< ? {
{ type: "movie" | "tv"; result: TvShowDetails | MovieDetails } | undefined type: "movie";
> { result: AppendToResponse<MovieDetails, "external_ids"[], "movie">;
try { }
const result = : {
type === "movie" type: "tv";
? await tmdb.movies.details(parseInt(id, 10), ["external_ids"]) result: AppendToResponse<TvShowDetails, "external_ids"[], "tvShow">;
: await tmdb.tvShows.details(parseInt(id, 10), ["external_ids"]); },
>(id: string, type: T): Promise<R | undefined> {
return { if (type === "movie") {
type, const movieResult = await tmdb.movies.details(parseInt(id, 10), [
result, "external_ids",
}; ]);
} catch (ex) { return { type: "movie", result: movieResult } as R;
return undefined;
} }
const tvResult = await tmdb.tvShows.details(parseInt(id, 10), [
"external_ids",
]);
return { type: "tv", result: tvResult } as R;
} }
export async function fetchSeasonDetails( export async function fetchSeasonDetails(

67
pnpm-lock.yaml generated
View File

@@ -4,11 +4,6 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
patchedDependencies:
nativewind@4.0.23:
hash: 42qwizvrnoqgalbele35lpnaqi
path: patches/nativewind@4.0.23.patch
importers: importers:
.: .:
@@ -46,6 +41,9 @@ importers:
'@react-navigation/native': '@react-navigation/native':
specifier: ^6.1.9 specifier: ^6.1.9
version: 6.1.9(react-native@0.73.2)(react@18.2.0) version: 6.1.9(react-native@0.73.2)(react@18.2.0)
'@tanstack/react-query':
specifier: ^5.22.2
version: 5.22.2(react@18.2.0)
class-variance-authority: class-variance-authority:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
@@ -92,8 +90,8 @@ importers:
specifier: ^10.0.3 specifier: ^10.0.3
version: 10.0.3 version: 10.0.3
nativewind: nativewind:
specifier: ~4.0.23 specifier: ^4.0.35
version: 4.0.23(patch_hash=42qwizvrnoqgalbele35lpnaqi)(@babel/core@7.23.9)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native-svg@14.1.0)(react-native@0.73.2)(react@18.2.0)(tailwindcss@3.4.1) version: 4.0.35(@babel/core@7.23.9)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native-svg@14.1.0)(react-native@0.73.2)(react@18.2.0)(tailwindcss@3.4.1)
react: react:
specifier: 18.2.0 specifier: 18.2.0
version: 18.2.0 version: 18.2.0
@@ -107,8 +105,8 @@ importers:
specifier: ^1.14.1 specifier: ^1.14.1
version: 1.14.1(react-native@0.73.2)(react@18.2.0) version: 1.14.1(react-native@0.73.2)(react@18.2.0)
react-native-css-interop: react-native-css-interop:
specifier: ~0.0.22 specifier: ^0.0.35
version: 0.0.22(@babel/core@7.23.9)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native-svg@14.1.0)(react-native@0.73.2)(react@18.2.0)(tailwindcss@3.4.1) version: 0.0.35(@babel/core@7.23.9)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native-svg@14.1.0)(react-native@0.73.2)(react@18.2.0)(tailwindcss@3.4.1)
react-native-gesture-handler: react-native-gesture-handler:
specifier: ~2.14.1 specifier: ~2.14.1
version: 2.14.1(react-native@0.73.2)(react@18.2.0) version: 2.14.1(react-native@0.73.2)(react@18.2.0)
@@ -167,6 +165,9 @@ importers:
'@movie-web/tsconfig': '@movie-web/tsconfig':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../../tooling/typescript version: link:../../tooling/typescript
'@tanstack/eslint-plugin-query':
specifier: ^5.20.1
version: 5.20.1(eslint@8.56.0)(typescript@5.3.3)
'@types/babel__core': '@types/babel__core':
specifier: ^7.20.5 specifier: ^7.20.5
version: 7.20.5 version: 7.20.5
@@ -3062,6 +3063,31 @@ packages:
'@sinonjs/commons': 3.0.1 '@sinonjs/commons': 3.0.1
dev: false dev: false
/@tanstack/eslint-plugin-query@5.20.1(eslint@8.56.0)(typescript@5.3.3):
resolution: {integrity: sha512-oIp7Wh90KHOm1FKCvcv87fiD2H96xo/crFrlhbvqBzR2f0tMEGOK/ANKMGNFQprd6BT6lyZhQPlOEkFdezsjIg==}
peerDependencies:
eslint: ^8.0.0
dependencies:
'@typescript-eslint/utils': 6.20.0(eslint@8.56.0)(typescript@5.3.3)
eslint: 8.56.0
transitivePeerDependencies:
- supports-color
- typescript
dev: true
/@tanstack/query-core@5.22.2:
resolution: {integrity: sha512-z3PwKFUFACMUqe1eyesCIKg3Jv1mysSrYfrEW5ww5DCDUD4zlpTKBvUDaEjsfZzL3ULrFLDM9yVUxI/fega1Qg==}
dev: false
/@tanstack/react-query@5.22.2(react@18.2.0):
resolution: {integrity: sha512-TaxJDRzJ8/NWRT4lY2jguKCrNI6MRN+67dELzPjNUlvqzTxGANlMp68l7aC7hG8Bd1uHNxHl7ihv7MT50i/43A==}
peerDependencies:
react: ^18.0.0
dependencies:
'@tanstack/query-core': 5.22.2
react: 18.2.0
dev: false
/@tootallnate/quickjs-emscripten@0.23.0: /@tootallnate/quickjs-emscripten@0.23.0:
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
dev: true dev: true
@@ -3236,7 +3262,6 @@ packages:
/@types/semver@7.5.6: /@types/semver@7.5.6:
resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==} resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==}
dev: false
/@types/stack-utils@2.0.3: /@types/stack-utils@2.0.3:
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
@@ -3324,7 +3349,6 @@ packages:
dependencies: dependencies:
'@typescript-eslint/types': 6.20.0 '@typescript-eslint/types': 6.20.0
'@typescript-eslint/visitor-keys': 6.20.0 '@typescript-eslint/visitor-keys': 6.20.0
dev: false
/@typescript-eslint/type-utils@6.20.0(eslint@8.56.0)(typescript@5.3.3): /@typescript-eslint/type-utils@6.20.0(eslint@8.56.0)(typescript@5.3.3):
resolution: {integrity: sha512-qnSobiJQb1F5JjN0YDRPHruQTrX7ICsmltXhkV536mp4idGAYrIyr47zF/JmkJtEcAVnIz4gUYJ7gOZa6SmN4g==} resolution: {integrity: sha512-qnSobiJQb1F5JjN0YDRPHruQTrX7ICsmltXhkV536mp4idGAYrIyr47zF/JmkJtEcAVnIz4gUYJ7gOZa6SmN4g==}
@@ -3349,7 +3373,6 @@ packages:
/@typescript-eslint/types@6.20.0: /@typescript-eslint/types@6.20.0:
resolution: {integrity: sha512-MM9mfZMAhiN4cOEcUOEx+0HmuaW3WBfukBZPCfwSqFnQy0grXYtngKCqpQN339X3RrwtzspWJrpbrupKYUSBXQ==} resolution: {integrity: sha512-MM9mfZMAhiN4cOEcUOEx+0HmuaW3WBfukBZPCfwSqFnQy0grXYtngKCqpQN339X3RrwtzspWJrpbrupKYUSBXQ==}
engines: {node: ^16.0.0 || >=18.0.0} engines: {node: ^16.0.0 || >=18.0.0}
dev: false
/@typescript-eslint/typescript-estree@6.20.0(typescript@5.3.3): /@typescript-eslint/typescript-estree@6.20.0(typescript@5.3.3):
resolution: {integrity: sha512-RnRya9q5m6YYSpBN7IzKu9FmLcYtErkDkc8/dKv81I9QiLLtVBHrjz+Ev/crAqgMNW2FCsoZF4g2QUylMnJz+g==} resolution: {integrity: sha512-RnRya9q5m6YYSpBN7IzKu9FmLcYtErkDkc8/dKv81I9QiLLtVBHrjz+Ev/crAqgMNW2FCsoZF4g2QUylMnJz+g==}
@@ -3371,7 +3394,6 @@ packages:
typescript: 5.3.3 typescript: 5.3.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
dev: false
/@typescript-eslint/utils@6.20.0(eslint@8.56.0)(typescript@5.3.3): /@typescript-eslint/utils@6.20.0(eslint@8.56.0)(typescript@5.3.3):
resolution: {integrity: sha512-/EKuw+kRu2vAqCoDwDCBtDRU6CTKbUmwwI7SH7AashZ+W+7o8eiyy6V2cdOqN49KsTcASWsC5QeghYuRDTyOOg==} resolution: {integrity: sha512-/EKuw+kRu2vAqCoDwDCBtDRU6CTKbUmwwI7SH7AashZ+W+7o8eiyy6V2cdOqN49KsTcASWsC5QeghYuRDTyOOg==}
@@ -3390,7 +3412,6 @@ packages:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
- typescript - typescript
dev: false
/@typescript-eslint/visitor-keys@6.20.0: /@typescript-eslint/visitor-keys@6.20.0:
resolution: {integrity: sha512-E8Cp98kRe4gKHjJD4NExXKz/zOJ1A2hhZc+IMVD6i7w4yjIvh6VyuRI0gRtxAsXtoC35uGMaQ9rjI2zJaXDEAw==} resolution: {integrity: sha512-E8Cp98kRe4gKHjJD4NExXKz/zOJ1A2hhZc+IMVD6i7w4yjIvh6VyuRI0gRtxAsXtoC35uGMaQ9rjI2zJaXDEAw==}
@@ -3398,7 +3419,6 @@ packages:
dependencies: dependencies:
'@typescript-eslint/types': 6.20.0 '@typescript-eslint/types': 6.20.0
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
dev: false
/@ungap/structured-clone@1.2.0: /@ungap/structured-clone@1.2.0:
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
@@ -6220,7 +6240,6 @@ packages:
ignore: 5.3.1 ignore: 5.3.1
merge2: 1.4.1 merge2: 1.4.1
slash: 3.0.0 slash: 3.0.0
dev: false
/gopd@1.0.1: /gopd@1.0.1:
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
@@ -7966,13 +7985,13 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
/nativewind@4.0.23(patch_hash=42qwizvrnoqgalbele35lpnaqi)(@babel/core@7.23.9)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native-svg@14.1.0)(react-native@0.73.2)(react@18.2.0)(tailwindcss@3.4.1): /nativewind@4.0.35(@babel/core@7.23.9)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native-svg@14.1.0)(react-native@0.73.2)(react@18.2.0)(tailwindcss@3.4.1):
resolution: {integrity: sha512-7eKMjcdoZMqxmPwJhLwe5VbuwCNTdIXChxV9n4FwdzKTpZX3kNGj95J7fpqpefFPRT6yYp6SqK2n6TG/BSzA+w==} resolution: {integrity: sha512-Sc7n6gwgrs/8t5u/PcBFdRyyCnSuLzJ/nfKQy8fRxgjiiAyrl3ExZvexQy+dEAcdK38vU++UYdRyHMf/mf6fWg==}
engines: {node: '>=16'} engines: {node: '>=16'}
peerDependencies: peerDependencies:
tailwindcss: '>3.3.0' tailwindcss: '>3.3.0'
dependencies: dependencies:
react-native-css-interop: 0.0.22(@babel/core@7.23.9)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native-svg@14.1.0)(react-native@0.73.2)(react@18.2.0)(tailwindcss@3.4.1) react-native-css-interop: 0.0.35(@babel/core@7.23.9)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native-svg@14.1.0)(react-native@0.73.2)(react@18.2.0)(tailwindcss@3.4.1)
tailwindcss: 3.4.1 tailwindcss: 3.4.1
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
@@ -7983,7 +8002,6 @@ packages:
- react-native-svg - react-native-svg
- supports-color - supports-color
dev: false dev: false
patched: true
/natural-compare@1.4.0: /natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -8989,13 +9007,13 @@ packages:
react-native: 0.73.2(@babel/core@7.23.9)(@babel/preset-env@7.23.9)(react@18.2.0) react-native: 0.73.2(@babel/core@7.23.9)(@babel/preset-env@7.23.9)(react@18.2.0)
dev: false dev: false
/react-native-css-interop@0.0.22(@babel/core@7.23.9)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native-svg@14.1.0)(react-native@0.73.2)(react@18.2.0)(tailwindcss@3.4.1): /react-native-css-interop@0.0.35(@babel/core@7.23.9)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native-svg@14.1.0)(react-native@0.73.2)(react@18.2.0)(tailwindcss@3.4.1):
resolution: {integrity: sha512-JHLYHlLEqM13dy0XSxIPOWvqmQkPrqUt+KHPkbLV0sIiw/4aN6B5TPsNKZFX9bJJaZ//dAECn782R0MqDrTBWQ==} resolution: {integrity: sha512-renqiX1UGsOIWUrDBzEaYQ1zapyTg69W7eIFvIYRZyEWPQ/16A+6pM8SkybOMu9pp7qInpUy888xmYhTQhg1UA==}
engines: {node: '>=16'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
react: '>=18' react: '>=18'
react-native: '*' react-native: '*'
react-native-reanimated: '>=3.3.0' react-native-reanimated: '>=3.6.2'
react-native-safe-area-context: '*' react-native-safe-area-context: '*'
react-native-svg: '*' react-native-svg: '*'
tailwindcss: ~3 tailwindcss: ~3
@@ -10326,7 +10344,6 @@ packages:
typescript: '>=4.2.0' typescript: '>=4.2.0'
dependencies: dependencies:
typescript: 5.3.3 typescript: 5.3.3
dev: false
/ts-interface-checker@0.1.13: /ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}

View File

@@ -4,6 +4,7 @@ const config = {
"plugin:react/recommended", "plugin:react/recommended",
"plugin:react-hooks/recommended", "plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended", "plugin:jsx-a11y/recommended",
"plugin:@tanstack/eslint-plugin-query/recommended",
], ],
rules: { rules: {
"react/prop-types": "off", "react/prop-types": "off",

View File

@@ -1,4 +1,27 @@
import {
black,
blue,
gray,
green,
indigo,
pink,
purple,
red,
white,
yellow,
} from "tailwindcss/colors";
export default { export default {
black: black,
white: white,
gray: gray,
red: red,
yellow: yellow,
green: green,
blue: blue,
indigo: indigo,
purple: purple,
pink: pink,
primary: { primary: {
100: "#C082FF", 100: "#C082FF",
300: "#8D44D6", 300: "#8D44D6",

View File

@@ -1,9 +1,14 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
import base from "./base"; import base from "./base";
import colors from "./colors";
export default { export default {
content: base.content, content: base.content,
presets: [base], presets: [base],
theme: {}, theme: {
extend: {
colors,
},
},
} satisfies Config; } satisfies Config;