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