mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 16:33:26 +00:00
downloads refactor
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
import type { Asset } from "expo-media-library";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Alert, Platform } from "react-native";
|
import { Alert, Platform } from "react-native";
|
||||||
import { ScrollView } from "react-native-gesture-handler";
|
import { ScrollView } from "react-native-gesture-handler";
|
||||||
@@ -12,13 +11,16 @@ import type { ScrapeMedia } from "@movie-web/provider-utils";
|
|||||||
import { DownloadItem } from "~/components/DownloadItem";
|
import { DownloadItem } from "~/components/DownloadItem";
|
||||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||||
import { MWButton } from "~/components/ui/Button";
|
import { MWButton } from "~/components/ui/Button";
|
||||||
import { useDownloadManager } from "~/hooks/DownloadManagerContext";
|
import { useDownloadManager } from "~/contexts/DownloadManagerContext";
|
||||||
|
import { PlayerStatus } from "~/stores/player/slices/interface";
|
||||||
import { usePlayerStore } from "~/stores/player/store";
|
import { usePlayerStore } from "~/stores/player/store";
|
||||||
|
|
||||||
const DownloadsScreen: React.FC = () => {
|
const DownloadsScreen: React.FC = () => {
|
||||||
const { startDownload, downloads } = useDownloadManager();
|
const { startDownload, downloads } = useDownloadManager();
|
||||||
const resetVideo = usePlayerStore((state) => state.resetVideo);
|
const resetVideo = usePlayerStore((state) => state.resetVideo);
|
||||||
const setAsset = usePlayerStore((state) => state.setAsset);
|
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
|
||||||
|
const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile);
|
||||||
|
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
@@ -39,10 +41,14 @@ const DownloadsScreen: React.FC = () => {
|
|||||||
}, [router]),
|
}, [router]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePress = (asset?: Asset) => {
|
const handlePress = (localPath?: string) => {
|
||||||
if (!asset) return;
|
if (!localPath) return;
|
||||||
resetVideo();
|
resetVideo();
|
||||||
setAsset(asset);
|
setIsLocalFile(true);
|
||||||
|
setPlayerStatus(PlayerStatus.READY);
|
||||||
|
setVideoSrc({
|
||||||
|
uri: localPath,
|
||||||
|
});
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/videoPlayer",
|
pathname: "/videoPlayer",
|
||||||
});
|
});
|
||||||
@@ -112,8 +118,8 @@ const DownloadsScreen: React.FC = () => {
|
|||||||
{downloads.map((item) => (
|
{downloads.map((item) => (
|
||||||
<DownloadItem
|
<DownloadItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
{...item}
|
item={item}
|
||||||
onPress={() => handlePress(item.asset)}
|
onPress={() => handlePress(item.localPath)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
@@ -118,8 +118,8 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenLayout>
|
<ScreenLayout>
|
||||||
<View padding={4}>
|
<View>
|
||||||
<YStack gap="$4">
|
<YStack gap="$8">
|
||||||
<YStack gap="$4">
|
<YStack gap="$4">
|
||||||
<Text fontSize="$7" fontWeight="$bold">
|
<Text fontSize="$7" fontWeight="$bold">
|
||||||
Appearance
|
Appearance
|
||||||
@@ -181,7 +181,7 @@ export default function SettingsScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
<DefaultQualitySelector qualityType="data" />
|
<DefaultQualitySelector qualityType="data" />
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack gap="$4" alignItems="center">
|
<XStack gap="$3" alignItems="center">
|
||||||
<Text fontWeight="$semibold" flexGrow={1}>
|
<Text fontWeight="$semibold" flexGrow={1}>
|
||||||
Allow downloads on mobile data
|
Allow downloads on mobile data
|
||||||
</Text>
|
</Text>
|
||||||
@@ -451,7 +451,7 @@ export function DefaultQualitySelector(props: DefaultQualitySelectorProps) {
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<MWSelect.Trigger
|
<MWSelect.Trigger
|
||||||
maxWidth="$12"
|
maxWidth="$10"
|
||||||
iconAfter={
|
iconAfter={
|
||||||
<FontAwesome name="chevron-down" color={theme.inputIconColor.val} />
|
<FontAwesome name="chevron-down" color={theme.inputIconColor.val} />
|
||||||
}
|
}
|
||||||
|
@@ -10,7 +10,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { TamaguiProvider, Theme, useTheme } from "tamagui";
|
import { TamaguiProvider, Theme, useTheme } from "tamagui";
|
||||||
import tamaguiConfig from "tamagui.config";
|
import tamaguiConfig from "tamagui.config";
|
||||||
|
|
||||||
import { DownloadManagerProvider } from "~/hooks/DownloadManagerContext";
|
import { DownloadManagerProvider } from "~/contexts/DownloadManagerContext";
|
||||||
import { useThemeStore } from "~/stores/theme";
|
import { useThemeStore } from "~/stores/theme";
|
||||||
// @ts-expect-error - Without named import it causes an infinite loop
|
// @ts-expect-error - Without named import it causes an infinite loop
|
||||||
import _styles from "../../tamagui-web.css";
|
import _styles from "../../tamagui-web.css";
|
||||||
|
@@ -11,7 +11,6 @@ import { usePlayerStore } from "~/stores/player/store";
|
|||||||
|
|
||||||
export default function VideoPlayerWrapper() {
|
export default function VideoPlayerWrapper() {
|
||||||
const playerStatus = usePlayerStore((state) => state.interface.playerStatus);
|
const playerStatus = usePlayerStore((state) => state.interface.playerStatus);
|
||||||
const asset = usePlayerStore((state) => state.asset);
|
|
||||||
const { presentFullscreenPlayer } = usePlayer();
|
const { presentFullscreenPlayer } = usePlayer();
|
||||||
|
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
@@ -32,10 +31,6 @@ export default function VideoPlayerWrapper() {
|
|||||||
|
|
||||||
void presentFullscreenPlayer();
|
void presentFullscreenPlayer();
|
||||||
|
|
||||||
if (asset) {
|
|
||||||
return <VideoPlayer />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (download) {
|
if (download) {
|
||||||
return <ScraperProcess data={data} download />;
|
return <ScraperProcess data={data} download />;
|
||||||
}
|
}
|
||||||
|
@@ -10,9 +10,9 @@ export function BrandPill() {
|
|||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
paddingHorizontal="$2.5"
|
paddingHorizontal="$3"
|
||||||
paddingVertical="$2"
|
paddingVertical="$2.5"
|
||||||
gap={2}
|
gap="$2.5"
|
||||||
opacity={0.8}
|
opacity={0.8}
|
||||||
backgroundColor="$pillBackground"
|
backgroundColor="$pillBackground"
|
||||||
borderRadius={24}
|
borderRadius={24}
|
||||||
@@ -24,11 +24,10 @@ export function BrandPill() {
|
|||||||
>
|
>
|
||||||
<MovieWebSvg
|
<MovieWebSvg
|
||||||
fillColor={theme.tabBarIconFocused.val}
|
fillColor={theme.tabBarIconFocused.val}
|
||||||
width={12}
|
width={20}
|
||||||
height={12}
|
height={20}
|
||||||
/>
|
/>
|
||||||
<Text fontSize="$4" fontWeight="$bold" paddingRight={5} paddingLeft={3}>
|
<Text fontSize="$6" fontWeight="$bold">
|
||||||
{/* padding might need adjusting */}
|
|
||||||
movie-web
|
movie-web
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
@@ -1,25 +1,17 @@
|
|||||||
import type { Asset } from "expo-media-library";
|
|
||||||
import type { NativeSyntheticEvent } from "react-native";
|
import type { NativeSyntheticEvent } from "react-native";
|
||||||
import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-view";
|
import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-view";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ContextMenu from "react-native-context-menu-view";
|
import ContextMenu from "react-native-context-menu-view";
|
||||||
import { TouchableOpacity } from "react-native-gesture-handler";
|
import { TouchableOpacity } from "react-native-gesture-handler";
|
||||||
import { Progress, Spinner, Text, View } from "tamagui";
|
import { Image, Text, View, XStack, YStack } from "tamagui";
|
||||||
|
|
||||||
import { useDownloadManager } from "~/hooks/DownloadManagerContext";
|
import type { Download } from "~/contexts/DownloadManagerContext";
|
||||||
|
import { useDownloadManager } from "~/contexts/DownloadManagerContext";
|
||||||
|
import { MWProgress } from "./ui/Progress";
|
||||||
|
|
||||||
export interface DownloadItemProps {
|
export interface DownloadItemProps {
|
||||||
id: string;
|
item: Download;
|
||||||
filename: string;
|
onPress: (localPath?: string) => void;
|
||||||
progress: number;
|
|
||||||
speed: number;
|
|
||||||
fileSize: number;
|
|
||||||
downloaded: number;
|
|
||||||
isFinished: boolean;
|
|
||||||
statusText?: string;
|
|
||||||
asset?: Asset;
|
|
||||||
isHLS?: boolean;
|
|
||||||
onPress: (asset?: Asset) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ContextMenuActions {
|
enum ContextMenuActions {
|
||||||
@@ -27,6 +19,15 @@ enum ContextMenuActions {
|
|||||||
Remove = "Remove",
|
Remove = "Remove",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusToTextMap: Record<Download["status"], string> = {
|
||||||
|
downloading: "Downloading",
|
||||||
|
finished: "Finished",
|
||||||
|
error: "Error",
|
||||||
|
merging: "Merging",
|
||||||
|
cancelled: "Cancelled",
|
||||||
|
importing: "Importing",
|
||||||
|
};
|
||||||
|
|
||||||
const formatBytes = (bytes: number, decimals = 2) => {
|
const formatBytes = (bytes: number, decimals = 2) => {
|
||||||
if (bytes === 0) return "0 Bytes";
|
if (bytes === 0) return "0 Bytes";
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -36,64 +37,28 @@ const formatBytes = (bytes: number, decimals = 2) => {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DownloadItem: React.FC<DownloadItemProps> = ({
|
export function DownloadItem(props: DownloadItemProps) {
|
||||||
id,
|
const percentage = props.item.progress * 100;
|
||||||
filename,
|
const formattedFileSize = formatBytes(props.item.fileSize);
|
||||||
progress,
|
const formattedDownloaded = formatBytes(props.item.downloaded);
|
||||||
speed,
|
|
||||||
fileSize,
|
|
||||||
downloaded,
|
|
||||||
isFinished,
|
|
||||||
statusText,
|
|
||||||
asset,
|
|
||||||
isHLS,
|
|
||||||
onPress,
|
|
||||||
}) => {
|
|
||||||
const percentage = progress * 100;
|
|
||||||
const formattedFileSize = formatBytes(fileSize);
|
|
||||||
const formattedDownloaded = formatBytes(downloaded);
|
|
||||||
const { removeDownload, cancelDownload } = useDownloadManager();
|
const { removeDownload, cancelDownload } = useDownloadManager();
|
||||||
|
|
||||||
const contextMenuActions = [
|
const contextMenuActions = [
|
||||||
{
|
{
|
||||||
title: ContextMenuActions.Remove,
|
title: ContextMenuActions.Remove,
|
||||||
},
|
},
|
||||||
...(!isFinished ? [{ title: ContextMenuActions.Cancel }] : []),
|
...(props.item.status !== "finished"
|
||||||
|
? [{ title: ContextMenuActions.Cancel }]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const onContextMenuPress = (
|
const onContextMenuPress = (
|
||||||
e: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>,
|
e: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>,
|
||||||
) => {
|
) => {
|
||||||
if (e.nativeEvent.name === ContextMenuActions.Cancel) {
|
if (e.nativeEvent.name === ContextMenuActions.Cancel) {
|
||||||
void cancelDownload(id);
|
void cancelDownload(props.item.id);
|
||||||
} else if (e.nativeEvent.name === ContextMenuActions.Remove) {
|
} else if (e.nativeEvent.name === ContextMenuActions.Remove) {
|
||||||
removeDownload(id);
|
removeDownload(props.item.id);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderStatus = () => {
|
|
||||||
if (statusText) {
|
|
||||||
return (
|
|
||||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
|
||||||
<Spinner size="small" color="$loadingIndicator" />
|
|
||||||
<Text fontSize={12} color="gray" style={{ marginLeft: 8 }}>
|
|
||||||
{statusText}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
} else if (isFinished) {
|
|
||||||
return (
|
|
||||||
<Text fontSize={12} color="gray">
|
|
||||||
Finished
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
if (isHLS) return null;
|
|
||||||
return (
|
|
||||||
<Text fontSize={12} color="gray">
|
|
||||||
{speed.toFixed(2)} MB/s
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,41 +68,63 @@ export const DownloadItem: React.FC<DownloadItemProps> = ({
|
|||||||
onPress={onContextMenuPress}
|
onPress={onContextMenuPress}
|
||||||
previewBackgroundColor="transparent"
|
previewBackgroundColor="transparent"
|
||||||
>
|
>
|
||||||
<TouchableOpacity onPress={() => onPress(asset)} activeOpacity={0.7}>
|
<TouchableOpacity
|
||||||
<View
|
onPress={() => props.onPress(props.item.localPath)}
|
||||||
marginBottom={16}
|
onLongPress={() => {
|
||||||
borderRadius={8}
|
return;
|
||||||
borderColor="white"
|
}}
|
||||||
padding={16}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text marginBottom={4} fontSize={16}>
|
<XStack gap="$4" alignItems="center">
|
||||||
{filename}
|
|
||||||
</Text>
|
|
||||||
<Progress
|
|
||||||
value={percentage}
|
|
||||||
height={10}
|
|
||||||
backgroundColor="$progressBackground"
|
|
||||||
>
|
|
||||||
<Progress.Indicator
|
|
||||||
animation="bounce"
|
|
||||||
backgroundColor="$progressFilled"
|
|
||||||
/>
|
|
||||||
</Progress>
|
|
||||||
<View
|
<View
|
||||||
marginTop={8}
|
aspectRatio={9 / 14}
|
||||||
flexDirection="row"
|
width={70}
|
||||||
alignItems="center"
|
maxHeight={180}
|
||||||
justifyContent="space-between"
|
overflow="hidden"
|
||||||
|
borderRadius="$2"
|
||||||
>
|
>
|
||||||
<Text fontSize={12} color="gray">
|
<Image
|
||||||
{isHLS
|
source={{
|
||||||
? `${percentage.toFixed()}% - ${downloaded} of ${fileSize} segments`
|
uri: "https://image.tmdb.org/t/p/original//or06FN3Dka5tukK1e9sl16pB3iy.jpg",
|
||||||
: `${percentage.toFixed()}% - ${formattedDownloaded} of ${formattedFileSize}`}
|
}}
|
||||||
</Text>
|
width="100%"
|
||||||
{renderStatus()}
|
height="100%"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<YStack gap="$2">
|
||||||
|
<XStack gap="$5">
|
||||||
|
<Text
|
||||||
|
fontWeight="$bold"
|
||||||
|
ellipse
|
||||||
|
maxWidth={props.item.type === "hls" ? "70%" : "40%"}
|
||||||
|
flexGrow={1}
|
||||||
|
>
|
||||||
|
{props.item.media.title}
|
||||||
|
</Text>
|
||||||
|
{props.item.type !== "hls" && (
|
||||||
|
<Text fontSize={12} color="gray">
|
||||||
|
{props.item.speed.toFixed(2)} MB/s
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</XStack>
|
||||||
|
<MWProgress value={percentage} height={10}>
|
||||||
|
<MWProgress.Indicator />
|
||||||
|
</MWProgress>
|
||||||
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
|
<Text fontSize={12} color="gray">
|
||||||
|
{props.item.type === "hls"
|
||||||
|
? `${percentage.toFixed()}% - ${props.item.downloaded} of ${props.item.fileSize} segments`
|
||||||
|
: `${percentage.toFixed()}% - ${formattedDownloaded} of ${formattedFileSize}`}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||||
|
<Text fontSize={12} color="gray">
|
||||||
|
{statusToTextMap[props.item.status]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</XStack>
|
||||||
|
</YStack>
|
||||||
|
</XStack>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
@@ -18,21 +18,12 @@ export const ItemListSection = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Text marginBottom={8} marginTop={16} fontWeight="500" fontSize={20}>
|
<Text marginBottom={8} marginTop={16} fontWeight="bold" fontSize="$8">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<ScrollView
|
<ScrollView horizontal={true} showsHorizontalScrollIndicator={false}>
|
||||||
horizontal={true}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={{ paddingHorizontal: 3 }}
|
|
||||||
>
|
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<View
|
<View key={index} width={itemWidth} paddingBottom={padding}>
|
||||||
key={index}
|
|
||||||
width={itemWidth}
|
|
||||||
paddingHorizontal={padding / 2}
|
|
||||||
paddingBottom={padding}
|
|
||||||
>
|
|
||||||
<Item data={item} />
|
<Item data={item} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
@@ -142,8 +142,8 @@ export default function Item({ data }: { data: ItemData }) {
|
|||||||
{type === "tv" ? "Show" : "Movie"}
|
{type === "tv" ? "Show" : "Movie"}
|
||||||
</Text>
|
</Text>
|
||||||
<View
|
<View
|
||||||
height={8}
|
height={6}
|
||||||
width={8}
|
width={6}
|
||||||
borderRadius={24}
|
borderRadius={24}
|
||||||
backgroundColor="$ash100"
|
backgroundColor="$ash100"
|
||||||
/>
|
/>
|
||||||
|
@@ -21,7 +21,7 @@ export function Header() {
|
|||||||
|
|
||||||
<Circle
|
<Circle
|
||||||
backgroundColor="$pillBackground"
|
backgroundColor="$pillBackground"
|
||||||
size="$2.5"
|
size="$4.5"
|
||||||
pressStyle={{
|
pressStyle={{
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
scale: 1.05,
|
scale: 1.05,
|
||||||
@@ -33,11 +33,11 @@ export function Header() {
|
|||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="discord" size={20} color="white" />
|
<MaterialIcons name="discord" size={32} color="white" />
|
||||||
</Circle>
|
</Circle>
|
||||||
<Circle
|
<Circle
|
||||||
backgroundColor="$pillBackground"
|
backgroundColor="$pillBackground"
|
||||||
size="$2.5"
|
size="$4.5"
|
||||||
pressStyle={{
|
pressStyle={{
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
scale: 1.05,
|
scale: 1.05,
|
||||||
@@ -49,7 +49,7 @@ export function Header() {
|
|||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FontAwesome6 name="github" size={20} color="white" />
|
<FontAwesome6 name="github" size={32} color="white" />
|
||||||
</Circle>
|
</Circle>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { View } from "tamagui";
|
import { ScrollView } from "tamagui";
|
||||||
import { LinearGradient } from "tamagui/linear-gradient";
|
import { LinearGradient } from "tamagui/linear-gradient";
|
||||||
|
|
||||||
import { Header } from "./Header";
|
import { Header } from "./Header";
|
||||||
@@ -12,7 +12,7 @@ export default function ScreenLayout({ children }: Props) {
|
|||||||
<LinearGradient
|
<LinearGradient
|
||||||
flex={1}
|
flex={1}
|
||||||
paddingVertical="$4"
|
paddingVertical="$4"
|
||||||
paddingHorizontal="$7"
|
paddingHorizontal="$4"
|
||||||
colors={[
|
colors={[
|
||||||
"$shade900",
|
"$shade900",
|
||||||
"$purple900",
|
"$purple900",
|
||||||
@@ -26,9 +26,13 @@ export default function ScreenLayout({ children }: Props) {
|
|||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
<Header />
|
<Header />
|
||||||
<View paddingVertical="$4" flexGrow={1}>
|
<ScrollView
|
||||||
|
marginTop="$4"
|
||||||
|
flexGrow={1}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</ScrollView>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -14,9 +14,10 @@ import { SettingsSelector } from "./SettingsSelector";
|
|||||||
import { SourceSelector } from "./SourceSelector";
|
import { SourceSelector } from "./SourceSelector";
|
||||||
import { mapMillisecondsToTime } from "./utils";
|
import { mapMillisecondsToTime } from "./utils";
|
||||||
|
|
||||||
export const BottomControls = ({ isLocalAsset }: { isLocalAsset: boolean }) => {
|
export const BottomControls = () => {
|
||||||
const status = usePlayerStore((state) => state.status);
|
const status = usePlayerStore((state) => state.status);
|
||||||
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
|
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
|
||||||
|
const isLocalFile = usePlayerStore((state) => state.isLocalFile);
|
||||||
const [showRemaining, setShowRemaining] = useState(false);
|
const [showRemaining, setShowRemaining] = useState(false);
|
||||||
|
|
||||||
const toggleTimeDisplay = useCallback(() => {
|
const toggleTimeDisplay = useCallback(() => {
|
||||||
@@ -76,7 +77,7 @@ export const BottomControls = ({ isLocalAsset }: { isLocalAsset: boolean }) => {
|
|||||||
gap={4}
|
gap={4}
|
||||||
paddingBottom={40}
|
paddingBottom={40}
|
||||||
>
|
>
|
||||||
{!isLocalAsset && (
|
{!isLocalFile && (
|
||||||
<>
|
<>
|
||||||
<SeasonSelector />
|
<SeasonSelector />
|
||||||
<CaptionsSelector />
|
<CaptionsSelector />
|
||||||
|
@@ -4,13 +4,7 @@ import { BottomControls } from "./BottomControls";
|
|||||||
import { Header } from "./Header";
|
import { Header } from "./Header";
|
||||||
import { MiddleControls } from "./MiddleControls";
|
import { MiddleControls } from "./MiddleControls";
|
||||||
|
|
||||||
export const ControlsOverlay = ({
|
export const ControlsOverlay = ({ isLoading }: { isLoading: boolean }) => {
|
||||||
isLoading,
|
|
||||||
isLocalAsset,
|
|
||||||
}: {
|
|
||||||
isLoading: boolean;
|
|
||||||
isLocalAsset: boolean;
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -20,7 +14,7 @@ export const ControlsOverlay = ({
|
|||||||
>
|
>
|
||||||
<Header />
|
<Header />
|
||||||
{!isLoading && <MiddleControls />}
|
{!isLoading && <MiddleControls />}
|
||||||
<BottomControls isLocalAsset={isLocalAsset} />
|
<BottomControls />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -3,7 +3,7 @@ import { useTheme } from "tamagui";
|
|||||||
|
|
||||||
import { findHighestQuality } from "@movie-web/provider-utils";
|
import { findHighestQuality } from "@movie-web/provider-utils";
|
||||||
|
|
||||||
import { useDownloadManager } from "~/hooks/DownloadManagerContext";
|
import { useDownloadManager } from "~/contexts/DownloadManagerContext";
|
||||||
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
||||||
import { usePlayerStore } from "~/stores/player/store";
|
import { usePlayerStore } from "~/stores/player/store";
|
||||||
import { MWButton } from "../ui/Button";
|
import { MWButton } from "../ui/Button";
|
||||||
|
@@ -18,7 +18,7 @@ import {
|
|||||||
import type { ItemData } from "../item/item";
|
import type { ItemData } from "../item/item";
|
||||||
import type { AudioTrack } from "./AudioTrackSelector";
|
import type { AudioTrack } from "./AudioTrackSelector";
|
||||||
import type { PlayerMeta } from "~/stores/player/slices/video";
|
import type { PlayerMeta } from "~/stores/player/slices/video";
|
||||||
import { useDownloadManager } from "~/hooks/DownloadManagerContext";
|
import { useDownloadManager } from "~/contexts/DownloadManagerContext";
|
||||||
import { useMeta } from "~/hooks/player/useMeta";
|
import { useMeta } from "~/hooks/player/useMeta";
|
||||||
import { useScrape } from "~/hooks/player/useSourceScrape";
|
import { useScrape } from "~/hooks/player/useSourceScrape";
|
||||||
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
||||||
|
@@ -11,7 +11,6 @@ import Animated, {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { ResizeMode, Video } from "expo-av";
|
import { ResizeMode, Video } from "expo-av";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import * as MediaLibrary from "expo-media-library";
|
|
||||||
import * as NavigationBar from "expo-navigation-bar";
|
import * as NavigationBar from "expo-navigation-bar";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import * as StatusBar from "expo-status-bar";
|
import * as StatusBar from "expo-status-bar";
|
||||||
@@ -63,7 +62,6 @@ export const VideoPlayer = () => {
|
|||||||
const stream = usePlayerStore((state) => state.interface.currentStream);
|
const stream = usePlayerStore((state) => state.interface.currentStream);
|
||||||
const selectedAudioTrack = useAudioTrackStore((state) => state.selectedTrack);
|
const selectedAudioTrack = useAudioTrackStore((state) => state.selectedTrack);
|
||||||
const videoRef = usePlayerStore((state) => state.videoRef);
|
const videoRef = usePlayerStore((state) => state.videoRef);
|
||||||
const asset = usePlayerStore((state) => state.asset);
|
|
||||||
const setVideoRef = usePlayerStore((state) => state.setVideoRef);
|
const setVideoRef = usePlayerStore((state) => state.setVideoRef);
|
||||||
const videoSrc = usePlayerStore((state) => state.videoSrc) ?? undefined;
|
const videoSrc = usePlayerStore((state) => state.videoSrc) ?? undefined;
|
||||||
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
|
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
|
||||||
@@ -73,6 +71,7 @@ export const VideoPlayer = () => {
|
|||||||
const toggleState = usePlayerStore((state) => state.toggleState);
|
const toggleState = usePlayerStore((state) => state.toggleState);
|
||||||
const meta = usePlayerStore((state) => state.meta);
|
const meta = usePlayerStore((state) => state.meta);
|
||||||
const setMeta = usePlayerStore((state) => state.setMeta);
|
const setMeta = usePlayerStore((state) => state.setMeta);
|
||||||
|
const isLocalFile = usePlayerStore((state) => state.isLocalFile);
|
||||||
|
|
||||||
const { gestureControls, autoPlay } = usePlayerSettingsStore();
|
const { gestureControls, autoPlay } = usePlayerSettingsStore();
|
||||||
const { updateWatchHistory, removeFromWatchHistory, getWatchHistoryItem } =
|
const { updateWatchHistory, removeFromWatchHistory, getWatchHistoryItem } =
|
||||||
@@ -151,15 +150,7 @@ export const VideoPlayer = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializePlayer = async () => {
|
const initializePlayer = async () => {
|
||||||
if (asset) {
|
if (videoSrc?.uri && isLocalFile) return;
|
||||||
const assetInfo = await MediaLibrary.getAssetInfoAsync(asset);
|
|
||||||
if (!assetInfo.localUri) return;
|
|
||||||
setVideoSrc({
|
|
||||||
uri: assetInfo.localUri,
|
|
||||||
});
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
await dismissFullscreenPlayer();
|
await dismissFullscreenPlayer();
|
||||||
@@ -194,7 +185,6 @@ export const VideoPlayer = () => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
void initializePlayer();
|
void initializePlayer();
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -217,7 +207,7 @@ export const VideoPlayer = () => {
|
|||||||
void synchronizePlayback();
|
void synchronizePlayback();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
asset,
|
isLocalFile,
|
||||||
dismissFullscreenPlayer,
|
dismissFullscreenPlayer,
|
||||||
hasStartedPlaying,
|
hasStartedPlaying,
|
||||||
meta,
|
meta,
|
||||||
@@ -228,6 +218,7 @@ export const VideoPlayer = () => {
|
|||||||
synchronizePlayback,
|
synchronizePlayback,
|
||||||
updateWatchHistory,
|
updateWatchHistory,
|
||||||
videoRef?.props.positionMillis,
|
videoRef?.props.positionMillis,
|
||||||
|
videoSrc?.uri,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const onVideoLoadStart = () => {
|
const onVideoLoadStart = () => {
|
||||||
@@ -322,7 +313,7 @@ export const VideoPlayer = () => {
|
|||||||
position="absolute"
|
position="absolute"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ControlsOverlay isLoading={isLoading} isLocalAsset={!!asset} />
|
<ControlsOverlay isLoading={isLoading} />
|
||||||
</View>
|
</View>
|
||||||
{showVolumeOverlay && <GestureOverlay value={volume} type="volume" />}
|
{showVolumeOverlay && <GestureOverlay value={volume} type="volume" />}
|
||||||
{showBrightnessOverlay && (
|
{showBrightnessOverlay && (
|
||||||
|
14
apps/expo/src/components/ui/Progress.tsx
Normal file
14
apps/expo/src/components/ui/Progress.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Progress, styled, withStaticProperties } from "tamagui";
|
||||||
|
|
||||||
|
const MWProgressFrame = styled(Progress, {
|
||||||
|
backgroundColor: "$progressBackground",
|
||||||
|
});
|
||||||
|
|
||||||
|
const MWProgressIndicator = styled(Progress.Indicator, {
|
||||||
|
backgroundColor: "$progressFilled",
|
||||||
|
animation: "bounce",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MWProgress = withStaticProperties(MWProgressFrame, {
|
||||||
|
Indicator: MWProgressIndicator,
|
||||||
|
});
|
@@ -23,23 +23,31 @@ import {
|
|||||||
useNetworkSettingsStore,
|
useNetworkSettingsStore,
|
||||||
} from "~/stores/settings";
|
} from "~/stores/settings";
|
||||||
|
|
||||||
export interface DownloadItem {
|
export interface Download {
|
||||||
id: string;
|
id: string;
|
||||||
filename: string;
|
|
||||||
progress: number;
|
progress: number;
|
||||||
speed: number;
|
speed: number;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
downloaded: number;
|
downloaded: number;
|
||||||
url: string;
|
url: string;
|
||||||
type: "mp4" | "hls";
|
type: "mp4" | "hls";
|
||||||
isFinished: boolean;
|
status:
|
||||||
statusText?: string;
|
| "downloading"
|
||||||
asset?: Asset;
|
| "finished"
|
||||||
isHLS?: boolean;
|
| "error"
|
||||||
|
| "merging"
|
||||||
|
| "cancelled"
|
||||||
|
| "importing";
|
||||||
|
localPath?: string;
|
||||||
media: ScrapeMedia;
|
media: ScrapeMedia;
|
||||||
downloadTask?: DownloadTask;
|
downloadTask?: DownloadTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DownloadContent {
|
||||||
|
media: Pick<ScrapeMedia, "title" | "releaseYear" | "type" | "tmdbId">;
|
||||||
|
downloads: Download[];
|
||||||
|
}
|
||||||
|
|
||||||
// @ts-expect-error - types are not up to date
|
// @ts-expect-error - types are not up to date
|
||||||
setConfig({
|
setConfig({
|
||||||
isLogsEnabled: false,
|
isLogsEnabled: false,
|
||||||
@@ -47,7 +55,7 @@ setConfig({
|
|||||||
});
|
});
|
||||||
|
|
||||||
interface DownloadManagerContextType {
|
interface DownloadManagerContextType {
|
||||||
downloads: DownloadItem[];
|
downloads: Download[];
|
||||||
startDownload: (
|
startDownload: (
|
||||||
url: string,
|
url: string,
|
||||||
type: "mp4" | "hls",
|
type: "mp4" | "hls",
|
||||||
@@ -75,7 +83,7 @@ export const useDownloadManager = () => {
|
|||||||
export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
|
export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
|
const [downloads, setDownloads] = useState<Download[]>([]);
|
||||||
const toastController = useToastController();
|
const toastController = useToastController();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -172,17 +180,15 @@ export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
duration: 500,
|
duration: 500,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newDownload: DownloadItem = {
|
const newDownload: Download = {
|
||||||
id: `download-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
id: `download-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
filename: url.split("/").pop() ?? "unknown",
|
|
||||||
progress: 0,
|
progress: 0,
|
||||||
speed: 0,
|
speed: 0,
|
||||||
fileSize: 0,
|
fileSize: 0,
|
||||||
downloaded: 0,
|
downloaded: 0,
|
||||||
type,
|
type,
|
||||||
url,
|
url,
|
||||||
isFinished: false,
|
status: "downloading",
|
||||||
isHLS: type === "hls",
|
|
||||||
media,
|
media,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -197,7 +203,7 @@ export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDownloadItem = (id: string, updates: Partial<DownloadItem>) => {
|
const updateDownloadItem = (id: string, updates: Partial<Download>) => {
|
||||||
setDownloads((currentDownloads) =>
|
setDownloads((currentDownloads) =>
|
||||||
currentDownloads.map((download) =>
|
currentDownloads.map((download) =>
|
||||||
download.id === id ? { ...download, ...updates } : download,
|
download.id === id ? { ...download, ...updates } : download,
|
||||||
@@ -342,7 +348,7 @@ export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return removeDownload(downloadId);
|
return removeDownload(downloadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDownloadItem(downloadId, { statusText: "Merging" });
|
updateDownloadItem(downloadId, { status: "merging" });
|
||||||
const uri = await VideoManager.mergeVideos(
|
const uri = await VideoManager.mergeVideos(
|
||||||
localSegmentPaths,
|
localSegmentPaths,
|
||||||
`${FileSystem.cacheDirectory}movie-web/output.mp4`,
|
`${FileSystem.cacheDirectory}movie-web/output.mp4`,
|
||||||
@@ -394,15 +400,14 @@ export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
downloadId: string,
|
downloadId: string,
|
||||||
): Promise<Asset | void> => {
|
): Promise<Asset | void> => {
|
||||||
try {
|
try {
|
||||||
updateDownloadItem(downloadId, { statusText: "Importing" });
|
updateDownloadItem(downloadId, { status: "importing" });
|
||||||
|
|
||||||
const asset = await MediaLibrary.createAssetAsync(fileUri);
|
const asset = await MediaLibrary.createAssetAsync(fileUri);
|
||||||
await FileSystem.deleteAsync(fileUri);
|
await FileSystem.deleteAsync(fileUri);
|
||||||
|
|
||||||
updateDownloadItem(downloadId, {
|
updateDownloadItem(downloadId, {
|
||||||
statusText: undefined,
|
status: "finished",
|
||||||
asset,
|
localPath: asset.uri,
|
||||||
isFinished: true,
|
|
||||||
});
|
});
|
||||||
console.log("File saved to media library and original deleted");
|
console.log("File saved to media library and original deleted");
|
||||||
toastController.show("Download finished", {
|
toastController.show("Download finished", {
|
@@ -1,5 +1,4 @@
|
|||||||
import type { AVPlaybackSourceObject, AVPlaybackStatus, Video } from "expo-av";
|
import type { AVPlaybackSourceObject, AVPlaybackStatus, Video } from "expo-av";
|
||||||
import type { Asset } from "expo-media-library";
|
|
||||||
|
|
||||||
import type { MakeSlice } from "./types";
|
import type { MakeSlice } from "./types";
|
||||||
import { PlayerStatus } from "./interface";
|
import { PlayerStatus } from "./interface";
|
||||||
@@ -31,13 +30,13 @@ export interface VideoSlice {
|
|||||||
videoSrc: AVPlaybackSourceObject | null;
|
videoSrc: AVPlaybackSourceObject | null;
|
||||||
status: AVPlaybackStatus | null;
|
status: AVPlaybackStatus | null;
|
||||||
meta: PlayerMeta | null;
|
meta: PlayerMeta | null;
|
||||||
asset: Asset | null;
|
isLocalFile: boolean;
|
||||||
|
|
||||||
setVideoRef(ref: Video | null): void;
|
setVideoRef(ref: Video | null): void;
|
||||||
setVideoSrc(src: AVPlaybackSourceObject | null): void;
|
setVideoSrc(src: AVPlaybackSourceObject | null): void;
|
||||||
setStatus(status: AVPlaybackStatus | null): void;
|
setStatus(status: AVPlaybackStatus | null): void;
|
||||||
setMeta(meta: PlayerMeta | null): void;
|
setMeta(meta: PlayerMeta | null): void;
|
||||||
setAsset(asset: Asset | null): void;
|
setIsLocalFile(isLocalFile: boolean): void;
|
||||||
resetVideo(): void;
|
resetVideo(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +45,7 @@ export const createVideoSlice: MakeSlice<VideoSlice> = (set) => ({
|
|||||||
videoSrc: null,
|
videoSrc: null,
|
||||||
status: null,
|
status: null,
|
||||||
meta: null,
|
meta: null,
|
||||||
asset: null,
|
isLocalFile: false,
|
||||||
|
|
||||||
setVideoRef: (ref) => {
|
setVideoRef: (ref) => {
|
||||||
set({ videoRef: ref });
|
set({ videoRef: ref });
|
||||||
@@ -67,13 +66,11 @@ export const createVideoSlice: MakeSlice<VideoSlice> = (set) => ({
|
|||||||
s.meta = meta;
|
s.meta = meta;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
setAsset: (asset) => {
|
setIsLocalFile: (isLocalFile) => {
|
||||||
set((s) => {
|
set({ isLocalFile });
|
||||||
s.asset = asset;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
resetVideo() {
|
resetVideo() {
|
||||||
set({ videoRef: null, status: null, meta: null, asset: null });
|
set({ videoRef: null, status: null, meta: null, isLocalFile: false });
|
||||||
set((s) => {
|
set((s) => {
|
||||||
s.interface.playerStatus = PlayerStatus.SCRAPING;
|
s.interface.playerStatus = PlayerStatus.SCRAPING;
|
||||||
});
|
});
|
||||||
|
@@ -7,7 +7,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
|||||||
import type { ScrapeMedia } from "@movie-web/provider-utils";
|
import type { ScrapeMedia } from "@movie-web/provider-utils";
|
||||||
|
|
||||||
import type { ItemData } from "~/components/item/item";
|
import type { ItemData } from "~/components/item/item";
|
||||||
import type { DownloadItem } from "~/hooks/DownloadManagerContext";
|
import type { Download } from "~/contexts/DownloadManagerContext";
|
||||||
import type { ThemeStoreOption } from "~/stores/theme";
|
import type { ThemeStoreOption } from "~/stores/theme";
|
||||||
|
|
||||||
const storage = new MMKV();
|
const storage = new MMKV();
|
||||||
@@ -77,8 +77,8 @@ export const usePlayerSettingsStore = create<
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface DownloadHistoryStoreState {
|
interface DownloadHistoryStoreState {
|
||||||
downloads: DownloadItem[];
|
downloads: Download[];
|
||||||
setDownloads: (downloads: DownloadItem[]) => void;
|
setDownloads: (downloads: Download[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDownloadHistoryStore = create<
|
export const useDownloadHistoryStore = create<
|
||||||
@@ -88,7 +88,7 @@ export const useDownloadHistoryStore = create<
|
|||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
downloads: [],
|
downloads: [],
|
||||||
setDownloads: (downloads: DownloadItem[]) => set({ downloads }),
|
setDownloads: (downloads: Download[]) => set({ downloads }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "download-history",
|
name: "download-history",
|
||||||
|
Reference in New Issue
Block a user