diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index bdae87f..e12b1d9 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -43,6 +43,7 @@ const defineConfig = (): ExpoConfig => ({ }, plugins: [ "expo-router", + "expo-video", [withRemoveiOSNotificationEntitlement as unknown as string], [ "expo-screen-orientation", diff --git a/apps/expo/package.json b/apps/expo/package.json index 89304ee..757e58d 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -21,80 +21,81 @@ "browserify-sign": "4.2.2" }, "dependencies": { - "@expo/metro-config": "^0.17.3", + "@expo/metro-config": "~0.17.1", "@movie-web/api": "workspace:*", "@movie-web/colors": "workspace:*", "@movie-web/provider-utils": "workspace:*", "@movie-web/tmdb": "workspace:*", "@octokit/rest": "^20.0.2", "@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0", - "@react-navigation/native": "^6.1.9", + "@react-navigation/native": "^6.1.18", "@salihgun/react-native-video-processor": "^0.3.1", - "@tamagui/animations-moti": "^1.96.0", - "@tamagui/babel-plugin": "^1.96.0", - "@tamagui/config": "^1.96.0", - "@tamagui/metro-plugin": "^1.96.0", - "@tamagui/toast": "1.96.0", - "@tanstack/react-query": "^5.22.2", - "ajv": "^8.13.0", + "@tamagui/animations-moti": "1.94.0", + "@tamagui/babel-plugin": "1.94.0", + "@tamagui/config": "1.94.0", + "@tamagui/metro-plugin": "1.94.0", + "@tamagui/toast": "1.94.0", + "@tanstack/react-query": "^5.51.23", + "ajv": "^8.17.1", "burnt": "^0.12.2", "class-variance-authority": "^0.7.0", - "expo": "~50.0.14", - "expo-alternate-app-icons": "^0.1.7", + "expo": "~50.0.20", + "expo-alternate-app-icons": "^0.1.9", "expo-application": "~5.8.3", "expo-av": "~13.10.5", "expo-brightness": "~11.8.0", "expo-build-properties": "~0.11.1", - "expo-clipboard": "^5.0.1", + "expo-clipboard": "~5.0.1", "expo-constants": "~15.4.5", "expo-file-system": "~16.0.8", "expo-haptics": "~12.8.1", "expo-keep-awake": "~12.8.2", - "expo-linear-gradient": "^12.7.2", + "expo-linear-gradient": "~12.7.2", "expo-linking": "~6.2.2", "expo-media-library": "~15.9.1", - "expo-navigation-bar": "^2.8.1", + "expo-navigation-bar": "~2.8.1", "expo-network": "~5.8.0", "expo-pod-pinner": "^1.0.1", "expo-router": "~3.4.10", "expo-screen-orientation": "~6.4.1", "expo-splash-screen": "~0.26.5", "expo-status-bar": "~1.11.1", - "expo-system-ui": "^2.9.3", - "expo-web-browser": "^12.8.2", + "expo-system-ui": "~2.9.3", + "expo-video": "~1.2.4", + "expo-web-browser": "~12.8.2", "ffmpeg-kit-react-native": "^6.0.2", - "immer": "^10.0.3", + "immer": "^10.1.1", "iso-639-1": "^3.1.2", - "react": "18.2.0", - "react-dom": "18.2.0", + "react": "~18.2.0", + "react-dom": "~18.2.0", "react-native": "0.73.6", "react-native-gesture-handler": "~2.14.1", "react-native-markdown-display": "^7.0.2", "react-native-mmkv": "^2.12.2", "react-native-modal": "^13.0.1", "react-native-quick-base64": "^2.1.2", - "react-native-quick-crypto": "^0.6.1", + "react-native-quick-crypto": "^0.7.3", "react-native-reanimated": "~3.6.2", - "react-native-safe-area-context": "~4.8.2", + "react-native-safe-area-context": "4.8.2", "react-native-screens": "~3.29.0", "react-native-svg": "14.1.0", - "react-native-web": "^0.19.10", + "react-native-web": "^0.19.12", "subsrt-ts": "^2.1.2", - "tamagui": "^1.94.0", + "tamagui": "1.94.0", "text-encoding-polyfill": "^0.6.7", - "zustand": "^4.4.7" + "zustand": "^4.5.4" }, "devDependencies": { - "@babel/core": "^7.23.9", - "@babel/preset-env": "^7.23.9", - "@babel/runtime": "^7.23.9", + "@babel/core": "^7.25.2", + "@babel/preset-env": "^7.25.3", + "@babel/runtime": "^7.25.0", "@movie-web/eslint-config": "workspace:^0.2.0", "@movie-web/prettier-config": "workspace:^0.1.0", "@movie-web/tsconfig": "workspace:^0.1.0", - "@tanstack/eslint-plugin-query": "^5.20.1", + "@tanstack/eslint-plugin-query": "^5.51.15", "@types/babel__core": "^7.20.5", - "@types/react": "~18.2.45", - "babel-plugin-module-resolver": "^5.0.0", + "@types/react": "~18.2.0", + "babel-plugin-module-resolver": "^5.0.2", "eslint": "^8.57.0", "prettier": "^3.2.5", "typescript": "^5.4.3" diff --git a/apps/expo/src/app/(tabs)/search.tsx b/apps/expo/src/app/(tabs)/search.tsx index 0f77f5f..c6d15bf 100644 --- a/apps/expo/src/app/(tabs)/search.tsx +++ b/apps/expo/src/app/(tabs)/search.tsx @@ -105,24 +105,26 @@ export default function SearchScreen() { scrollEnabled={searchResultsLoaded ? true : false} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" - contentContainerStyle={{ flexGrow: 1 }} + contentContainerStyle={{ + flexGrow: 1, + alignItems: "center", + justifyContent: "center", + }} > - - - - {data?.map((item, index) => ( - - - - ))} - - - + + + {data?.map((item, index) => ( + + + + ))} + + - + - + {title} - + {type === "tv" ? "Show" : "Movie"} diff --git a/apps/expo/src/components/player/BottomControls.tsx b/apps/expo/src/components/player/BottomControls.tsx index 7642e49..030befd 100644 --- a/apps/expo/src/components/player/BottomControls.tsx +++ b/apps/expo/src/components/player/BottomControls.tsx @@ -18,10 +18,10 @@ import { ProgressBar } from "./ProgressBar"; import { SeasonSelector } from "./SeasonEpisodeSelector"; import { SettingsSelector } from "./SettingsSelector"; import { SourceSelector } from "./SourceSelector"; -import { mapMillisecondsToTime } from "./utils"; +import { mapSecondsToTime } from "./utils"; export const BottomControls = () => { - const status = usePlayerStore((state) => state.status); + const player = usePlayerStore((state) => state.player); const isIdle = usePlayerStore((state) => state.interface.isIdle); const setIsIdle = usePlayerStore((state) => state.setIsIdle); const isLocalFile = usePlayerStore((state) => state.isLocalFile); @@ -33,25 +33,23 @@ export const BottomControls = () => { }, [showRemaining, setIsIdle]); const { currentTime, remainingTime } = useMemo(() => { - if (status?.isLoaded) { - const current = mapMillisecondsToTime(status.positionMillis ?? 0); - const remaining = `-${mapMillisecondsToTime( - (status.durationMillis ?? 0) - (status.positionMillis ?? 0), + if (player) { + const current = mapSecondsToTime(player.currentTime); + const remaining = `-${mapSecondsToTime( + (player.duration ?? 0) - (player.currentTime ?? 0), )}`; return { currentTime: current, remainingTime: remaining }; } else { return { - currentTime: mapMillisecondsToTime(0), - remainingTime: mapMillisecondsToTime(0), + currentTime: mapSecondsToTime(0), + remainingTime: mapSecondsToTime(0), }; } - }, [status]); + }, [player]); const durationTime = useMemo(() => { - return mapMillisecondsToTime( - status?.isLoaded ? status.durationMillis ?? 0 : 0, - ); - }, [status]); + return mapSecondsToTime(player?.duration ?? 0); + }, [player]); const translateY = useSharedValue(128); diff --git a/apps/expo/src/components/player/CaptionRenderer.tsx b/apps/expo/src/components/player/CaptionRenderer.tsx index f41b94c..e9bff94 100644 --- a/apps/expo/src/components/player/CaptionRenderer.tsx +++ b/apps/expo/src/components/player/CaptionRenderer.tsx @@ -8,7 +8,6 @@ import Animated, { } from "react-native-reanimated"; import { Text, View } from "tamagui"; -import { convertMilliSecondsToSeconds } from "~/lib/number"; import { useCaptionsStore } from "~/stores/captions"; import { usePlayerStore } from "~/stores/player/store"; @@ -27,10 +26,10 @@ export const captionIsVisible = ( }; export const CaptionRenderer = () => { + const player = usePlayerStore((state) => state.player); 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); @@ -56,20 +55,12 @@ export const CaptionRenderer = () => { const visibleCaptions = useMemo( () => selectedCaption?.data.filter(({ start, end }) => - captionIsVisible( - start, - end, - delay, - status?.isLoaded - ? convertMilliSecondsToSeconds(status.positionMillis) - : 0, - ), + captionIsVisible(start, end, delay, player ? player.currentTime : 0), ), - [selectedCaption, delay, status], + [selectedCaption, player, delay], ); - if (!status?.isLoaded || !selectedCaption || !visibleCaptions?.length) - return null; + if (!player || !selectedCaption || !visibleCaptions?.length) return null; return ( { - const videoRef = usePlayerStore((state) => state.videoRef); - const status = usePlayerStore((state) => state.status); + const player = usePlayerStore((state) => state.player); const playAudio = usePlayerStore((state) => state.playAudio); const pauseAudio = usePlayerStore((state) => state.pauseAudio); - if ( - status?.isLoaded && - !status.isPlaying && - status.isBuffering && - status.positionMillis > status.playableDurationMillis! - ) { + if (!player) return null; + + if (player.status === "loading") { return ; } return ( { - if (status?.isLoaded) { - if (status.isPlaying) { - videoRef?.pauseAsync().catch(() => { - console.log("Error pausing video"); - }); - void pauseAudio(); - } else { - videoRef?.playAsync().catch(() => { - console.log("Error playing video"); - }); - void playAudio(); - } + if (player.playing) { + void pauseAudio(); + } else { + void playAudio(); + } + + if (!player.playing) { + player.play(); + void playAudio(); } }} /> diff --git a/apps/expo/src/components/player/PlaybackSpeedSelector.tsx b/apps/expo/src/components/player/PlaybackSpeedSelector.tsx index 955b608..ea9c5fa 100644 --- a/apps/expo/src/components/player/PlaybackSpeedSelector.tsx +++ b/apps/expo/src/components/player/PlaybackSpeedSelector.tsx @@ -44,11 +44,8 @@ export const PlaybackSpeedSelector = (props: SheetProps) => { ) } onPress={() => { - changePlaybackSpeed(speed) - .then(() => props.onOpenChange?.(false)) - .catch((err) => { - console.log("error", err); - }); + changePlaybackSpeed(speed); + props.onOpenChange?.(false); }} /> ))} diff --git a/apps/expo/src/components/player/ProgressBar.tsx b/apps/expo/src/components/player/ProgressBar.tsx index c7fedfe..ec9bbcc 100644 --- a/apps/expo/src/components/player/ProgressBar.tsx +++ b/apps/expo/src/components/player/ProgressBar.tsx @@ -5,19 +5,19 @@ import { usePlayerStore } from "~/stores/player/store"; import VideoSlider from "./VideoSlider"; export const ProgressBar = () => { - const status = usePlayerStore((state) => state.status); - const videoRef = usePlayerStore((state) => state.videoRef); + const player = usePlayerStore((state) => state.player); const setIsIdle = usePlayerStore((state) => state.setIsIdle); const updateProgress = useCallback( (newProgress: number) => { - videoRef?.setStatusAsync({ positionMillis: newProgress }).catch(() => { - console.error("Error updating progress"); - }); + if (!player) return; + player.currentTime = newProgress * player.duration; }, - [videoRef], + [player], ); + if (!player) return null; + return ( { paddingTop: 24, }} onPress={() => setIsIdle(false)} - disabled={!status?.isLoaded} + disabled={player.status !== "readyToPlay"} > diff --git a/apps/expo/src/components/player/QualitySelector.tsx b/apps/expo/src/components/player/QualitySelector.tsx index 5f766a9..d449c77 100644 --- a/apps/expo/src/components/player/QualitySelector.tsx +++ b/apps/expo/src/components/player/QualitySelector.tsx @@ -9,12 +9,12 @@ import { Settings } from "./settings/Sheet"; export const QualitySelector = (props: SheetProps) => { const theme = useTheme(); - const videoRef = usePlayerStore((state) => state.videoRef); + const player = usePlayerStore((state) => state.player); const videoSrc = usePlayerStore((state) => state.videoSrc); const stream = usePlayerStore((state) => state.interface.currentStream); const hlsTracks = usePlayerStore((state) => state.interface.hlsTracks); - if (!videoRef || !videoSrc || !stream) return null; + if (!player || !videoSrc || !stream) return null; let qualityMap: { quality: string; url: string }[]; let currentQuality: string | undefined; @@ -77,11 +77,10 @@ export const QualitySelector = (props: SheetProps) => { ) } onPress={() => { - void videoRef.unloadAsync(); - void videoRef.loadAsync( - { uri: quality.url, headers: stream.headers }, - { shouldPlay: true }, - ); + player.replace({ + uri: quality.url, + headers: stream.headers, + }); }} /> ))} diff --git a/apps/expo/src/components/player/SeekButton.tsx b/apps/expo/src/components/player/SeekButton.tsx index 54be900..19b0e81 100644 --- a/apps/expo/src/components/player/SeekButton.tsx +++ b/apps/expo/src/components/player/SeekButton.tsx @@ -7,29 +7,24 @@ interface SeekProps { } export const SeekButton = ({ type }: SeekProps) => { - const videoRef = usePlayerStore((state) => state.videoRef); - const status = usePlayerStore((state) => state.status); + const player = usePlayerStore((state) => state.player); const setAudioPositionAsync = usePlayerStore( (state) => state.setAudioPositionAsync, ); + if (!player) return null; + return ( { - if (status?.isLoaded) { - const position = - type === "forward" - ? status.positionMillis + 10000 - : status.positionMillis - 10000; - - videoRef?.setPositionAsync(position).catch(() => { - console.log("Error seeking backwards"); - }); - void setAudioPositionAsync(position); - } + player.currentTime = + type === "forward" + ? player.currentTime + 10000 + : player.currentTime - 10000; + void setAudioPositionAsync(player.currentTime); }} /> ); diff --git a/apps/expo/src/components/player/VideoPlayer.tsx b/apps/expo/src/components/player/VideoPlayer.tsx index 9fd6611..8a582c3 100644 --- a/apps/expo/src/components/player/VideoPlayer.tsx +++ b/apps/expo/src/components/player/VideoPlayer.tsx @@ -1,6 +1,5 @@ -import type { AVPlaybackStatus } from "expo-av"; import type { SharedValue } from "react-native-reanimated"; -import { useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Dimensions, Platform } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { @@ -9,13 +8,14 @@ import Animated, { useSharedValue, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { ResizeMode, Video } from "expo-av"; +import { ResizeMode } from "expo-av"; import * as Haptics from "expo-haptics"; import { useKeepAwake } from "expo-keep-awake"; import * as NavigationBar from "expo-navigation-bar"; import * as Network from "expo-network"; import { useRouter } from "expo-router"; import * as StatusBar from "expo-status-bar"; +import { useVideoPlayer, VideoView } from "expo-video"; import { Feather } from "@expo/vector-icons"; import { Spinner, useTheme, View } from "tamagui"; @@ -23,7 +23,6 @@ import { findHLSQuality, findQuality } from "@movie-web/provider-utils"; import { useAudioTrack } from "~/hooks/player/useAudioTrack"; import { useBrightness } from "~/hooks/player/useBrightness"; -import { usePlaybackSpeed } from "~/hooks/player/usePlaybackSpeed"; import { usePlayer } from "~/hooks/player/usePlayer"; import { useVolume } from "~/hooks/player/useVolume"; import { @@ -44,6 +43,7 @@ import { ControlsOverlay } from "./ControlsOverlay"; export const VideoPlayer = () => { useKeepAwake(); + const { brightness, showBrightnessOverlay, @@ -52,12 +52,10 @@ export const VideoPlayer = () => { } = useBrightness(); const { volume, showVolumeOverlay, setShowVolumeOverlay } = useVolume(); - const { currentSpeed } = usePlaybackSpeed(); const { synchronizePlayback } = useAudioTrack(); const { dismissFullscreenPlayer } = usePlayer(); const [isLoading, setIsLoading] = useState(true); const [resizeMode, setResizeMode] = useState(ResizeMode.CONTAIN); - const [hasStartedPlaying, setHasStartedPlaying] = useState(false); const router = useRouter(); const scale = useSharedValue(1); @@ -66,12 +64,9 @@ export const VideoPlayer = () => { const isIdle = usePlayerStore((state) => state.interface.isIdle); const stream = usePlayerStore((state) => state.interface.currentStream); const selectedAudioTrack = useAudioTrackStore((state) => state.selectedTrack); - const videoRef = usePlayerStore((state) => state.videoRef); - const setVideoRef = usePlayerStore((state) => state.setVideoRef); - const videoSrc = usePlayerStore((state) => state.videoSrc) ?? undefined; + const videoSrc = usePlayerStore((state) => state.videoSrc); const setVideoSrc = usePlayerStore((state) => state.setVideoSrc); - const setStatus = usePlayerStore((state) => state.setStatus); - const status = usePlayerStore((state) => state.status); + const setVideoPlayer = usePlayerStore((state) => state.setVideoPlayer); const setIsIdle = usePlayerStore((state) => state.setIsIdle); const toggleAudio = usePlayerStore((state) => state.toggleAudio); const toggleState = usePlayerStore((state) => state.toggleState); @@ -79,6 +74,27 @@ export const VideoPlayer = () => { const setMeta = usePlayerStore((state) => state.setMeta); const isLocalFile = usePlayerStore((state) => state.isLocalFile); + const player = useVideoPlayer(videoSrc, (player) => { + if (state === "playing") { + player.play(); + } + + if (meta) { + const media = convertMetaToScrapeMedia(meta); + const watchHistoryItem = getWatchHistoryItem(media); + + if (watchHistoryItem) { + player.currentTime = watchHistoryItem.positionMillis / 1000; + } + } + }); + + useEffect(() => { + if (player) { + setVideoPlayer(player); + } + }, [player, setVideoPlayer]); + const { gestureControls, autoPlay } = usePlayerSettingsStore(); const { updateWatchHistory, removeFromWatchHistory, getWatchHistoryItem } = useWatchHistoryStore(); @@ -142,6 +158,7 @@ export const VideoPlayer = () => { } else { runOnJS(setShowBrightnessOverlay)(false); } + player.volume = volume.value; }); const composedGesture = Gesture.Race( @@ -158,7 +175,7 @@ export const VideoPlayer = () => { useEffect(() => { const initializePlayer = async () => { - if (videoSrc?.uri && isLocalFile) return; + if (isLocalFile) return; if (!stream) { await dismissFullscreenPlayer(); @@ -203,8 +220,9 @@ export const VideoPlayer = () => { void initializePlayer(); const timeout = setTimeout(() => { - if (!hasStartedPlaying) { - router.back(); + if (player.status === "loading") { + void dismissFullscreenPlayer(); + void router.back(); } }, 60000); @@ -212,19 +230,16 @@ export const VideoPlayer = () => { if (meta) { const item = convertMetaToItemData(meta); const scrapeMedia = convertMetaToScrapeMedia(meta); - updateWatchHistory( - item, - scrapeMedia, - videoRef?.props.positionMillis ?? 0, - ); + updateWatchHistory(item, scrapeMedia, player.currentTime); } clearTimeout(timeout); void synchronizePlayback(); }; }, [ + player.currentTime, + player.status, isLocalFile, dismissFullscreenPlayer, - hasStartedPlaying, meta, router, selectedAudioTrack, @@ -232,57 +247,38 @@ export const VideoPlayer = () => { stream, synchronizePlayback, updateWatchHistory, - videoRef?.props.positionMillis, - videoSrc?.uri, wifiDefaultQuality, mobileDataDefaultQuality, ]); - const onVideoLoadStart = () => { - setIsLoading(true); - }; - - const onReadyForDisplay = () => { - setIsLoading(false); - setHasStartedPlaying(true); - if (videoRef) { - void videoRef.setRateAsync(currentSpeed, true); - - if (meta) { - const media = convertMetaToScrapeMedia(meta); - const watchHistoryItem = getWatchHistoryItem(media); + useEffect(() => { + const playerStatusChange = player.addListener("statusChange", (status) => { + const isFinished = player.duration - player.currentTime < 1; + if (meta && status === "idle" && meta.type === "movie" && isFinished) { + const item = convertMetaToItemData(meta); + removeFromWatchHistory(item); + } - if (watchHistoryItem) { - void videoRef.setPositionAsync(watchHistoryItem.positionMillis); - } + if (autoPlay && status === "idle" && meta?.type === "show") { + getNextEpisode(meta) + .then((nextEpisodeMeta) => { + if (!nextEpisodeMeta) return; + setMeta(nextEpisodeMeta); + const media = convertMetaToScrapeMedia(nextEpisodeMeta); + + router.replace({ + pathname: "/videoPlayer", + params: { media: JSON.stringify(media) }, + }); + }) + .catch(console.error); } - } - }; + }); - const onPlaybackStatusUpdate = async (status: AVPlaybackStatus) => { - setStatus(status); - if (meta && status.isLoaded && status.didJustFinish) { - const item = convertMetaToItemData(meta); - removeFromWatchHistory(item); - } - if ( - status.isLoaded && - status.didJustFinish && - !status.isLooping && - autoPlay - ) { - if (meta?.type !== "show") return; - const nextEpisodeMeta = await getNextEpisode(meta); - if (!nextEpisodeMeta) return; - setMeta(nextEpisodeMeta); - const media = convertMetaToScrapeMedia(nextEpisodeMeta); - - router.replace({ - pathname: "/videoPlayer", - params: { media: JSON.stringify(media) }, - }); - } - }; + return () => { + playerStatusChange.remove(); + }; + }, [player, meta, removeFromWatchHistory, autoPlay, setMeta, router]); return ( @@ -293,16 +289,8 @@ export const VideoPlayer = () => { justifyContent="center" backgroundColor="black" > -