mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 16:23:25 +00:00
improve loading, caption renderer, season/episode selector, source selector
This commit is contained in:
@@ -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",
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
@@ -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 };
|
|
||||||
}
|
|
||||||
|
@@ -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) },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@@ -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(() => {
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@@ -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}>
|
||||||
|
@@ -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}
|
||||||
|
@@ -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>
|
||||||
|
@@ -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" />
|
||||||
|
@@ -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} />
|
||||||
|
115
apps/expo/src/components/player/ScraperProcess.tsx
Normal file
115
apps/expo/src/components/player/ScraperProcess.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@@ -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>
|
||||||
);
|
);
|
||||||
|
@@ -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>
|
||||||
|
222
apps/expo/src/components/player/VideoPlayer.tsx
Normal file
222
apps/expo/src/components/player/VideoPlayer.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
5
apps/expo/src/components/ui/Divider.tsx
Normal file
5
apps/expo/src/components/ui/Divider.tsx
Normal 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" />;
|
||||||
|
};
|
21
apps/expo/src/hooks/player/usePlayer.ts
Normal file
21
apps/expo/src/hooks/player/usePlayer.ts
Normal 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;
|
||||||
|
};
|
89
apps/expo/src/hooks/player/useSourceScrape.ts
Normal file
89
apps/expo/src/hooks/player/useSourceScrape.ts
Normal 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;
|
||||||
|
};
|
@@ -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",
|
||||||
|
},
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@@ -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";
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -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 };
|
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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;
|
||||||
|
}
|
||||||
|
@@ -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
67
pnpm-lock.yaml
generated
@@ -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==}
|
||||||
|
1
tooling/eslint/react.js
vendored
1
tooling/eslint/react.js
vendored
@@ -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",
|
||||||
|
@@ -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",
|
||||||
|
@@ -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;
|
||||||
|
Reference in New Issue
Block a user