diff --git a/apps/expo/package.json b/apps/expo/package.json index 0026c21..f0b5e44 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -45,12 +45,14 @@ "react-native-context-menu-view": "^1.14.1", "react-native-css-interop": "~0.0.22", "react-native-gesture-handler": "~2.14.1", + "react-native-modal": "^13.0.1", "react-native-quick-base64": "^2.0.8", "react-native-quick-crypto": "^0.6.1", "react-native-reanimated": "~3.6.2", "react-native-safe-area-context": "~4.8.2", "react-native-screens": "~3.29.0", "react-native-web": "^0.19.10", + "subsrt-ts": "^2.1.2", "tailwind-merge": "^2.2.1", "zustand": "^4.4.7" }, diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index 967bfd4..d078918 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -22,6 +22,7 @@ import { import type { ItemData } from "~/components/item/item"; import type { HeaderData } from "~/components/player/Header"; +import { CaptionRenderer } from "~/components/player/CaptionRenderer"; import { ControlsOverlay } from "~/components/player/ControlsOverlay"; import { Text } from "~/components/ui/Text"; import { useBrightness } from "~/hooks/player/useBrightness"; @@ -69,9 +70,10 @@ const VideoPlayer: React.FC = ({ data }) => { 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 isIdle = usePlayerStore((state) => state.interface.isIdle); const setIsIdle = usePlayerStore((state) => state.setIsIdle); const presentFullscreenPlayer = usePlayerStore( (state) => state.presentFullscreenPlayer, @@ -160,6 +162,8 @@ const VideoPlayer: React.FC = ({ data }) => { const { item, stream, media } = data; + setStream(stream); + setHeaderData({ title: item.title, year: item.year, @@ -252,6 +256,7 @@ const VideoPlayer: React.FC = ({ data }) => { Brightness: {debouncedBrightness} )} + ); diff --git a/apps/expo/src/components/player/BottomControls.tsx b/apps/expo/src/components/player/BottomControls.tsx index 035df80..8ece3a2 100644 --- a/apps/expo/src/components/player/BottomControls.tsx +++ b/apps/expo/src/components/player/BottomControls.tsx @@ -3,15 +3,18 @@ import { TouchableOpacity, View } from "react-native"; import { usePlayerStore } from "~/stores/player/store"; import { Text } from "../ui/Text"; +import { CaptionsSelector } from "./CaptionsSelector"; import { Controls } from "./Controls"; import { ProgressBar } from "./ProgressBar"; import { mapMillisecondsToTime } from "./utils"; export const BottomControls = () => { const status = usePlayerStore((state) => state.status); + const setIsIdle = usePlayerStore((state) => state.setIsIdle); const [showRemaining, setShowRemaining] = useState(false); const toggleTimeDisplay = () => { + setIsIdle(false); setShowRemaining(!showRemaining); }; @@ -32,9 +35,9 @@ export const BottomControls = () => { if (status?.isLoaded) { return ( - - - + + + {getCurrentTime()} / @@ -46,9 +49,12 @@ export const BottomControls = () => { - + + + + diff --git a/apps/expo/src/components/player/CaptionRenderer.tsx b/apps/expo/src/components/player/CaptionRenderer.tsx new file mode 100644 index 0000000..85023da --- /dev/null +++ b/apps/expo/src/components/player/CaptionRenderer.tsx @@ -0,0 +1,101 @@ +import { useMemo } from "react"; +import { View } from "react-native"; +import Animated, { + useAnimatedReaction, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withSpring, +} from "react-native-reanimated"; + +import { Text } from "~/components/ui/Text"; +import { convertMilliSecondsToSeconds } from "~/lib/number"; +import { useCaptionsStore } from "~/stores/captions"; +import { usePlayerStore } from "~/stores/player/store"; + +export const captionIsVisible = ( + start: number, + end: number, + delay: number, + currentTime: number, +) => { + const delayedStart = start / 1000 + delay; + const delayedEnd = end / 1000 + delay; + return ( + Math.max(0, delayedStart) <= currentTime && + Math.max(0, delayedEnd) >= currentTime + ); +}; + +export const CaptionRenderer = () => { + const isIdle = usePlayerStore((state) => state.interface.isIdle); + const selectedCaption = useCaptionsStore((state) => state.selectedCaption); + const delay = useCaptionsStore((state) => state.delay); + const status = usePlayerStore((state) => state.status); + + const translateY = useSharedValue(0); + + const animatedStyles = useAnimatedStyle(() => { + return { + transform: [{ translateY: translateY.value }], + }; + }); + + const transitionValue = useDerivedValue(() => { + return isIdle ? 50 : 0; + }, [isIdle]); + + useAnimatedReaction( + () => { + return transitionValue.value; + }, + (newValue) => { + translateY.value = withSpring(newValue); + }, + ); + + const visibleCaptions = useMemo( + () => + selectedCaption?.data.filter(({ start, end }) => + captionIsVisible( + start, + end, + delay, + status?.isLoaded + ? convertMilliSecondsToSeconds(status.positionMillis) + : 0, + ), + ), + [selectedCaption, delay, status], + ); + + console.log(visibleCaptions); + + if (!status?.isLoaded || !selectedCaption || !visibleCaptions?.length) + return null; + + return ( + // https://github.com/marklawlor/nativewind/issues/790 + + {visibleCaptions?.map((caption) => ( + + {caption.text} + + ))} + + ); +}; diff --git a/apps/expo/src/components/player/CaptionsSelector.tsx b/apps/expo/src/components/player/CaptionsSelector.tsx new file mode 100644 index 0000000..957da04 --- /dev/null +++ b/apps/expo/src/components/player/CaptionsSelector.tsx @@ -0,0 +1,84 @@ +import type { ContentCaption } from "subsrt-ts/dist/types/handler"; +import { useCallback } from "react"; +import { ScrollView, View } from "react-native"; +import Modal from "react-native-modal"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { parse } from "subsrt-ts"; + +import type { Stream } from "@movie-web/provider-utils"; +import colors from "@movie-web/tailwind-config/colors"; + +import { useBoolean } from "~/hooks/useBoolean"; +import { useCaptionsStore } from "~/stores/captions"; +import { usePlayerStore } from "~/stores/player/store"; +import { Button } from "../ui/Button"; +import { Text } from "../ui/Text"; + +const parseCaption = async ( + caption: Stream["captions"][0], +): Promise => { + const response = await fetch(caption.url); + const data = await response.text(); + return parse(data).filter( + (cue) => cue.type === "caption", + ) as ContentCaption[]; +}; + +export const CaptionsSelector = () => { + const captions = usePlayerStore((state) => state.interface.stream?.captions); + const setSelectedCaption = useCaptionsStore( + (state) => state.setSelectedCaption, + ); + const { isTrue, on, off } = useBoolean(); + + const downloadAndSetCaption = useCallback( + (caption: Stream["captions"][0]) => { + parseCaption(caption) + .then((data) => { + setSelectedCaption({ ...caption, data }); + }) + .catch(console.error); + }, + [setSelectedCaption], + ); + + if (!captions?.length) return null; + + return ( + +