mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 18:13:25 +00:00
improve loading, caption renderer, season/episode selector, source selector
This commit is contained in:
@@ -5,6 +5,7 @@ import ContextMenu from "react-native-context-menu-view";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
import { Text } from "~/components/ui/Text";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
|
||||
export interface ItemData {
|
||||
id: string;
|
||||
@@ -15,13 +16,15 @@ export interface ItemData {
|
||||
}
|
||||
|
||||
export default function Item({ data }: { data: ItemData }) {
|
||||
const resetVideo = usePlayerStore((state) => state.resetVideo);
|
||||
const router = useRouter();
|
||||
const { title, type, year, posterUrl } = data;
|
||||
|
||||
const handlePress = () => {
|
||||
resetVideo();
|
||||
Keyboard.dismiss();
|
||||
router.push({
|
||||
pathname: "/videoPlayer/loading",
|
||||
pathname: "/videoPlayer",
|
||||
params: { data: JSON.stringify(data) },
|
||||
});
|
||||
};
|
||||
|
@@ -2,19 +2,19 @@ import { Keyboard } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { usePlayer } from "~/hooks/player/usePlayer";
|
||||
|
||||
export const BackButton = ({
|
||||
className,
|
||||
}: Partial<React.ComponentProps<typeof Ionicons>>) => {
|
||||
const unlockOrientation = usePlayerStore((state) => state.unlockOrientation);
|
||||
const { dismissFullscreenPlayer } = usePlayer();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Ionicons
|
||||
name="arrow-back"
|
||||
onPress={() => {
|
||||
unlockOrientation()
|
||||
dismissFullscreenPlayer()
|
||||
.then(() => {
|
||||
router.back();
|
||||
return setTimeout(() => {
|
||||
|
@@ -6,7 +6,7 @@ import { Text } from "../ui/Text";
|
||||
import { CaptionsSelector } from "./CaptionsSelector";
|
||||
import { Controls } from "./Controls";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { SeasonEpisodeSelector } from "./SeasonEpisodeSelector";
|
||||
import { SeasonSelector } from "./SeasonEpisodeSelector";
|
||||
import { SourceSelector } from "./SourceSelector";
|
||||
import { mapMillisecondsToTime } from "./utils";
|
||||
|
||||
@@ -36,32 +36,28 @@ export const BottomControls = () => {
|
||||
|
||||
if (status?.isLoaded) {
|
||||
return (
|
||||
<Controls>
|
||||
<View className="flex h-40 w-full flex-col items-center justify-center p-6">
|
||||
<View className="w-full">
|
||||
<View className="flex flex-row items-center">
|
||||
<Text className="font-bold">{getCurrentTime()}</Text>
|
||||
<Text className="mx-1 font-bold">/</Text>
|
||||
<TouchableOpacity onPress={toggleTimeDisplay}>
|
||||
<Text className="font-bold">
|
||||
{showRemaining
|
||||
? getRemainingTime()
|
||||
: mapMillisecondsToTime(status.durationMillis ?? 0)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<ProgressBar />
|
||||
</View>
|
||||
<View className="flex w-full flex-row items-center justify-start">
|
||||
<SeasonEpisodeSelector />
|
||||
<CaptionsSelector />
|
||||
<SourceSelector />
|
||||
</View>
|
||||
<View className="flex h-32 w-full flex-col items-center justify-center p-6">
|
||||
<Controls>
|
||||
<View className="flex w-full flex-row items-center">
|
||||
<Text className="font-bold">{getCurrentTime()}</Text>
|
||||
<Text className="mx-1 font-bold">/</Text>
|
||||
<TouchableOpacity onPress={toggleTimeDisplay}>
|
||||
<Text className="font-bold">
|
||||
{showRemaining
|
||||
? getRemainingTime()
|
||||
: mapMillisecondsToTime(status.durationMillis ?? 0)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ProgressBar />
|
||||
</Controls>
|
||||
<View className="flex w-full flex-row items-center justify-center gap-4 pb-10">
|
||||
<SeasonSelector />
|
||||
<CaptionsSelector />
|
||||
<SourceSelector />
|
||||
</View>
|
||||
</Controls>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@@ -69,28 +69,13 @@ export const CaptionRenderer = () => {
|
||||
[selectedCaption, delay, status],
|
||||
);
|
||||
|
||||
console.log(visibleCaptions);
|
||||
|
||||
if (!status?.isLoaded || !selectedCaption || !visibleCaptions?.length)
|
||||
return null;
|
||||
|
||||
return (
|
||||
// https://github.com/marklawlor/nativewind/issues/790
|
||||
<Animated.View
|
||||
// className="rounded px-4 py-1 text-center leading-normal [text-shadow:0_2px_4px_rgba(0,0,0,0.5)]"
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
borderRadius: 10,
|
||||
bottom: 100,
|
||||
},
|
||||
animatedStyles,
|
||||
]}
|
||||
className="absolute bottom-24 rounded bg-black/50 px-4 py-1 text-center leading-normal"
|
||||
style={animatedStyles}
|
||||
>
|
||||
{visibleCaptions?.map((caption) => (
|
||||
<View key={caption.index}>
|
||||
|
@@ -13,6 +13,7 @@ import { useCaptionsStore } from "~/stores/captions";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Text } from "../ui/Text";
|
||||
import { Controls } from "./Controls";
|
||||
|
||||
const parseCaption = async (
|
||||
caption: Stream["captions"][0],
|
||||
@@ -25,7 +26,9 @@ const parseCaption = async (
|
||||
};
|
||||
|
||||
export const CaptionsSelector = () => {
|
||||
const captions = usePlayerStore((state) => state.interface.stream?.captions);
|
||||
const captions = usePlayerStore(
|
||||
(state) => state.interface.currentStream?.captions,
|
||||
);
|
||||
const setSelectedCaption = useCaptionsStore(
|
||||
(state) => state.setSelectedCaption,
|
||||
);
|
||||
@@ -46,18 +49,20 @@ export const CaptionsSelector = () => {
|
||||
|
||||
return (
|
||||
<View className="max-w-36 flex-1">
|
||||
<Button
|
||||
title="Subtitles"
|
||||
variant="outline"
|
||||
onPress={on}
|
||||
iconLeft={
|
||||
<MaterialCommunityIcons
|
||||
name="subtitles"
|
||||
size={24}
|
||||
color={colors.primary[300]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Controls>
|
||||
<Button
|
||||
title="Subtitles"
|
||||
variant="outline"
|
||||
onPress={on}
|
||||
iconLeft={
|
||||
<MaterialCommunityIcons
|
||||
name="subtitles"
|
||||
size={24}
|
||||
color={colors.primary[300]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Controls>
|
||||
|
||||
<Modal
|
||||
isVisible={isTrue}
|
||||
|
@@ -1,18 +1,13 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import type { HeaderData } from "./Header";
|
||||
import { BottomControls } from "./BottomControls";
|
||||
import { Header } from "./Header";
|
||||
import { MiddleControls } from "./MiddleControls";
|
||||
|
||||
interface ControlsOverlayProps {
|
||||
headerData: HeaderData;
|
||||
}
|
||||
|
||||
export const ControlsOverlay = ({ headerData }: ControlsOverlayProps) => {
|
||||
export const ControlsOverlay = () => {
|
||||
return (
|
||||
<View className="absolute left-0 top-0 flex h-full w-full flex-1 flex-col justify-between">
|
||||
<Header data={headerData} />
|
||||
<View className="flex w-full flex-1 flex-col justify-between">
|
||||
<Header />
|
||||
<MiddleControls />
|
||||
<BottomControls />
|
||||
</View>
|
||||
|
@@ -6,30 +6,28 @@ import { Text } from "../ui/Text";
|
||||
import { BackButton } from "./BackButton";
|
||||
import { Controls } from "./Controls";
|
||||
|
||||
export interface HeaderData {
|
||||
title: string;
|
||||
year: number;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
}
|
||||
const mapSeasonAndEpisodeNumberToText = (season: number, episode: number) => {
|
||||
return `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
interface HeaderProps {
|
||||
data: HeaderData;
|
||||
}
|
||||
|
||||
export const Header = ({ data }: HeaderProps) => {
|
||||
export const Header = () => {
|
||||
const isIdle = usePlayerStore((state) => state.interface.isIdle);
|
||||
const meta = usePlayerStore((state) => state.meta);
|
||||
|
||||
if (!isIdle) {
|
||||
if (!isIdle && meta) {
|
||||
return (
|
||||
<View className="z-50 flex h-16 w-full flex-row justify-between px-6 pt-6">
|
||||
<Controls>
|
||||
<BackButton className="w-36" />
|
||||
</Controls>
|
||||
<Text className="font-bold">
|
||||
{data.season !== undefined && data.episode !== undefined
|
||||
? `${data.title} (${data.year}) S${data.season.toString().padStart(2, "0")}E${data.episode.toString().padStart(2, "0")}`
|
||||
: `${data.title} (${data.year})`}
|
||||
{meta.title} ({meta.releaseYear}){" "}
|
||||
{meta.season !== undefined && meta.episode !== undefined
|
||||
? mapSeasonAndEpisodeNumberToText(
|
||||
meta.season.number,
|
||||
meta.episode.number,
|
||||
)
|
||||
: ""}
|
||||
</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">
|
||||
<Image source={Icon} className="h-6 w-6" />
|
||||
|
@@ -21,7 +21,7 @@ export const ProgressBar = () => {
|
||||
if (status?.isLoaded) {
|
||||
return (
|
||||
<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)}
|
||||
>
|
||||
<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 { useRouter } from "expo-router";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import colors from "@movie-web/tailwind-config/colors";
|
||||
import { fetchMediaDetails, fetchSeasonDetails } from "@movie-web/tmdb";
|
||||
|
||||
import { useBoolean } from "~/hooks/useBoolean";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Divider } from "../ui/Divider";
|
||||
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 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">
|
||||
<Button
|
||||
title="Episode"
|
||||
variant="outline"
|
||||
onPress={on}
|
||||
iconLeft={
|
||||
<MaterialCommunityIcons
|
||||
name="audio-video"
|
||||
size={24}
|
||||
color={colors.primary[300]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Controls>
|
||||
<Button
|
||||
title="Episode"
|
||||
variant="outline"
|
||||
onPress={on}
|
||||
iconLeft={
|
||||
<MaterialCommunityIcons
|
||||
name="audio-video"
|
||||
size={24}
|
||||
color={colors.primary[300]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Controls>
|
||||
|
||||
<Modal
|
||||
isVisible={isTrue}
|
||||
onBackdropPress={off}
|
||||
supportedOrientations={["portrait", "landscape"]}
|
||||
style={{
|
||||
width: "35%",
|
||||
justifyContent: "center",
|
||||
alignSelf: "center",
|
||||
}}
|
||||
>
|
||||
<ScrollView className="flex-1 bg-gray-900">
|
||||
<Text className="text-center font-bold">Select episode</Text>
|
||||
{seasonData.episodes.map((episode) => (
|
||||
<Button
|
||||
key={episode.id}
|
||||
title={episode.name}
|
||||
onPress={() => {
|
||||
off();
|
||||
router.push({
|
||||
// replace throws exception
|
||||
pathname: "/videoPlayer/loading",
|
||||
params: {
|
||||
data: JSON.stringify(data),
|
||||
seasonData: JSON.stringify({
|
||||
season: seasonData.season_number,
|
||||
episode: episode.episode_number,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="max-w-16"
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
{selectedSeason === null && (
|
||||
<>
|
||||
{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,
|
||||
}}
|
||||
>
|
||||
<Text className="text-center font-bold">
|
||||
{data.result.name}
|
||||
</Text>
|
||||
<Divider />
|
||||
{data.result.seasons.map((season) => (
|
||||
<TouchableOpacity
|
||||
key={season.season_number}
|
||||
className="m-1 flex flex-row items-center p-2"
|
||||
onPress={() => setSelectedSeason(season.season_number)}
|
||||
>
|
||||
<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>
|
||||
</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 { useRouter } from "expo-router";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Ionicons, 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 {
|
||||
useEmbedScrape,
|
||||
useSourceScrape,
|
||||
} from "~/hooks/player/useSourceScrape";
|
||||
import { useBoolean } from "~/hooks/useBoolean";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Text } from "../ui/Text";
|
||||
import { Controls } from "./Controls";
|
||||
|
||||
export const SourceSelector = () => {
|
||||
const data = usePlayerStore((state) => state.interface.data);
|
||||
const { isTrue, on, off } = useBoolean();
|
||||
const router = useRouter();
|
||||
const SourceItem = ({
|
||||
name,
|
||||
id,
|
||||
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 (
|
||||
<View className="max-w-36 flex-1">
|
||||
<Button
|
||||
title="Source"
|
||||
variant="outline"
|
||||
onPress={on}
|
||||
iconLeft={
|
||||
<MaterialCommunityIcons
|
||||
name="video"
|
||||
size={24}
|
||||
color={colors.primary[300]}
|
||||
/>
|
||||
<Pressable
|
||||
className="flex w-full flex-row justify-between p-3"
|
||||
onPress={() => {
|
||||
if (onPress) {
|
||||
onPress(id);
|
||||
return;
|
||||
}
|
||||
/>
|
||||
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
|
||||
isVisible={isTrue}
|
||||
onBackdropPress={off}
|
||||
supportedOrientations={["portrait", "landscape"]}
|
||||
style={{
|
||||
width: "35%",
|
||||
justifyContent: "center",
|
||||
alignSelf: "center",
|
||||
}}
|
||||
>
|
||||
<ScrollView className="flex-1 bg-gray-900">
|
||||
<Text className="text-center font-bold">Select source</Text>
|
||||
{getBuiltinSources().map((source) => (
|
||||
<Button
|
||||
key={source.id}
|
||||
title={source.name}
|
||||
onPress={() => {
|
||||
off();
|
||||
router.push({
|
||||
// replace throws exception
|
||||
pathname: "/videoPlayer/loading",
|
||||
params: { sourceID: source.id, data: JSON.stringify(data) },
|
||||
});
|
||||
}}
|
||||
className="max-w-16"
|
||||
<ScrollView
|
||||
className="w-full flex-1 bg-gray-900"
|
||||
contentContainerStyle={{
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
{currentScreen === "source" && (
|
||||
<>
|
||||
{getBuiltinSources()
|
||||
.sort((a, b) => b.rank - a.rank)
|
||||
.map((source) => (
|
||||
<SourceItem
|
||||
key={source.id}
|
||||
name={source.name}
|
||||
id={source.id}
|
||||
active={isActive(source.id)}
|
||||
onPress={() => {
|
||||
setSourceId(source.id);
|
||||
setCurrentScreen("embed");
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{currentScreen === "embed" && (
|
||||
<EmbedsPart
|
||||
sourceId={sourceId!}
|
||||
setCurrentScreen={setCurrentScreen}
|
||||
closeModal={off}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</ScrollView>
|
||||
</Modal>
|
||||
</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" />;
|
||||
};
|
Reference in New Issue
Block a user