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

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

View File

@@ -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) },
});
};

View File

@@ -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(() => {

View File

@@ -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>
);
}
};

View File

@@ -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}>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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} />

View File

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

View File

@@ -1,65 +1,182 @@
import { ScrollView, View } from "react-native";
import { useState } from "react";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import Modal from "react-native-modal";
import { 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>
);

View File

@@ -1,58 +1,193 @@
import { ScrollView, View } from "react-native";
import { useCallback, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
import Modal from "react-native-modal";
import { 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>

View File

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

View File

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