mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 16:43:25 +00:00
add episode download section
This commit is contained in:
61
apps/expo/src/app/(downloads)/[tmdbId].tsx
Normal file
61
apps/expo/src/app/(downloads)/[tmdbId].tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from "react";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { YStack } from "tamagui";
|
||||
|
||||
import { DownloadItem } from "~/components/DownloadItem";
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { PlayerStatus } from "~/stores/player/slices/interface";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { useDownloadHistoryStore } from "~/stores/settings";
|
||||
|
||||
export default function Page() {
|
||||
const { tmdbId } = useLocalSearchParams();
|
||||
const allDownloads = useDownloadHistoryStore((state) => state.downloads);
|
||||
const resetVideo = usePlayerStore((state) => state.resetVideo);
|
||||
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
|
||||
const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile);
|
||||
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
|
||||
const router = useRouter();
|
||||
|
||||
const download = useMemo(() => {
|
||||
return allDownloads.find((download) => download.media.tmdbId === tmdbId);
|
||||
}, [allDownloads, tmdbId]);
|
||||
|
||||
const handlePress = (localPath?: string) => {
|
||||
if (!localPath) return;
|
||||
resetVideo();
|
||||
setIsLocalFile(true);
|
||||
setPlayerStatus(PlayerStatus.READY);
|
||||
setVideoSrc({
|
||||
uri: localPath,
|
||||
});
|
||||
router.push({
|
||||
pathname: "/videoPlayer",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenLayout showHeader={false}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: download?.media.title ?? "Downloads",
|
||||
}}
|
||||
/>
|
||||
<YStack gap="$3">
|
||||
{download?.downloads.map((download) => {
|
||||
return (
|
||||
<DownloadItem
|
||||
key={
|
||||
download.media.type === "show"
|
||||
? download.media.episode.tmdbId
|
||||
: download.media.tmdbId
|
||||
}
|
||||
item={download}
|
||||
onPress={() => handlePress(download.localPath)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
14
apps/expo/src/app/(downloads)/_layout.tsx
Normal file
14
apps/expo/src/app/(downloads)/_layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
import { BrandPill } from "~/components/BrandPill";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTransparent: true,
|
||||
headerRight: BrandPill,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@@ -7,7 +7,7 @@ import { ScrollView, useTheme, YStack } from "tamagui";
|
||||
|
||||
import type { ScrapeMedia } from "@movie-web/provider-utils";
|
||||
|
||||
import { DownloadItem } from "~/components/DownloadItem";
|
||||
import { DownloadItem, ShowDownloadItem } from "~/components/DownloadItem";
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { useDownloadManager } from "~/hooks/useDownloadManager";
|
||||
@@ -15,15 +15,69 @@ import { PlayerStatus } from "~/stores/player/slices/interface";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { useDownloadHistoryStore } from "~/stores/settings";
|
||||
|
||||
const DownloadsScreen: React.FC = () => {
|
||||
const exampleMovieMedia: ScrapeMedia = {
|
||||
type: "movie",
|
||||
title: "Avengers: Endgame",
|
||||
releaseYear: 2019,
|
||||
imdbId: "tt4154796",
|
||||
tmdbId: "299534",
|
||||
};
|
||||
|
||||
const getExampleShowMedia = (seasonNumber: number, episodeNumber: number) =>
|
||||
({
|
||||
type: "show",
|
||||
title: "Loki",
|
||||
releaseYear: 2021,
|
||||
imdbId: "tt9140554",
|
||||
tmdbId: "84958",
|
||||
season: {
|
||||
number: seasonNumber,
|
||||
tmdbId: seasonNumber.toString(),
|
||||
},
|
||||
episode: {
|
||||
number: episodeNumber,
|
||||
tmdbId: episodeNumber.toString(),
|
||||
},
|
||||
}) as const;
|
||||
|
||||
const TestDownloadButton = (props: {
|
||||
media: ScrapeMedia;
|
||||
type: "hls" | "mp4";
|
||||
url: string;
|
||||
}) => {
|
||||
const { startDownload } = useDownloadManager();
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<MWButton
|
||||
type="secondary"
|
||||
backgroundColor="$sheetItemBackground"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="download"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={async () => {
|
||||
await startDownload(props.url, props.type, props.media).catch(
|
||||
console.error,
|
||||
);
|
||||
}}
|
||||
>
|
||||
test download
|
||||
{props.type === "hls" ? " (hls)" : "(mp4)"}{" "}
|
||||
{props.media.type === "show" ? "show" : "movie"}
|
||||
</MWButton>
|
||||
);
|
||||
};
|
||||
|
||||
const DownloadsScreen: React.FC = () => {
|
||||
const downloads = useDownloadHistoryStore((state) => state.downloads);
|
||||
const resetVideo = usePlayerStore((state) => state.resetVideo);
|
||||
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
|
||||
const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile);
|
||||
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
@@ -55,85 +109,54 @@ const DownloadsScreen: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const exampleShowMedia: ScrapeMedia = {
|
||||
type: "show",
|
||||
title: "Example Show Title",
|
||||
releaseYear: 2022,
|
||||
imdbId: "tt1234567",
|
||||
tmdbId: "12345",
|
||||
season: {
|
||||
number: 1,
|
||||
tmdbId: "54321",
|
||||
},
|
||||
episode: {
|
||||
number: 3,
|
||||
tmdbId: "98765",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenLayout>
|
||||
<YStack gap={2} style={{ padding: 10 }}>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
backgroundColor="$sheetItemBackground"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="download"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={async () => {
|
||||
await startDownload(
|
||||
"https://samplelib.com/lib/preview/mp4/sample-5s.mp4",
|
||||
"mp4",
|
||||
exampleShowMedia,
|
||||
).catch(console.error);
|
||||
}}
|
||||
>
|
||||
test download (mp4)
|
||||
</MWButton>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
backgroundColor="$sheetItemBackground"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="download"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={async () => {
|
||||
await startDownload(
|
||||
"http://sample.vodobox.com/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8",
|
||||
"hls",
|
||||
{
|
||||
...exampleShowMedia,
|
||||
tmdbId: "123456",
|
||||
},
|
||||
).catch(console.error);
|
||||
}}
|
||||
>
|
||||
test download (hls)
|
||||
</MWButton>
|
||||
<TestDownloadButton
|
||||
media={exampleMovieMedia}
|
||||
type="mp4"
|
||||
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||
/>
|
||||
<TestDownloadButton
|
||||
media={getExampleShowMedia(1, 1)}
|
||||
type="mp4"
|
||||
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||
/>
|
||||
<TestDownloadButton
|
||||
media={getExampleShowMedia(1, 2)}
|
||||
type="mp4"
|
||||
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||
/>
|
||||
<TestDownloadButton
|
||||
media={getExampleShowMedia(1, 1)}
|
||||
type="hls"
|
||||
url="http://sample.vodobox.com/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8"
|
||||
/>
|
||||
</YStack>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
gap: "$4",
|
||||
}}
|
||||
>
|
||||
{/* TODO: Differentiate movies/shows, shows in new page */}
|
||||
{downloads
|
||||
.map((item) => item.downloads)
|
||||
.flat()
|
||||
.map((item) => (
|
||||
<DownloadItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onPress={() => handlePress(item.localPath)}
|
||||
/>
|
||||
))}
|
||||
{downloads.map((download) => {
|
||||
if (download.downloads.length === 0) return null;
|
||||
if (download.media.type === "movie") {
|
||||
return (
|
||||
<DownloadItem
|
||||
key={download.media.tmdbId}
|
||||
item={download.downloads[0]!}
|
||||
onPress={() => handlePress(download.downloads[0]!.localPath)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ShowDownloadItem
|
||||
key={download.media.tmdbId}
|
||||
download={download}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ScrollView>
|
||||
</ScreenLayout>
|
||||
);
|
||||
|
@@ -3,10 +3,12 @@ import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-vi
|
||||
import React from "react";
|
||||
import ContextMenu from "react-native-context-menu-view";
|
||||
import { TouchableOpacity } from "react-native-gesture-handler";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Image, Text, View, XStack, YStack } from "tamagui";
|
||||
|
||||
import type { Download } from "~/hooks/useDownloadManager";
|
||||
import type { Download, DownloadContent } from "~/hooks/useDownloadManager";
|
||||
import { useDownloadManager } from "~/hooks/useDownloadManager";
|
||||
import { mapSeasonAndEpisodeNumberToText } from "./player/utils";
|
||||
import { MWProgress } from "./ui/Progress";
|
||||
import { FlashingText } from "./ui/Text";
|
||||
|
||||
@@ -101,6 +103,11 @@ export function DownloadItem(props: DownloadItemProps) {
|
||||
<YStack gap="$2">
|
||||
<XStack gap="$6" maxWidth="65%">
|
||||
<Text fontWeight="$bold" ellipse flexGrow={1}>
|
||||
{props.item.media.type === "show" &&
|
||||
mapSeasonAndEpisodeNumberToText(
|
||||
props.item.media.season.number,
|
||||
props.item.media.episode.number,
|
||||
) + " "}
|
||||
{props.item.media.title}
|
||||
</Text>
|
||||
{props.item.type !== "hls" && (
|
||||
@@ -136,3 +143,54 @@ export function DownloadItem(props: DownloadItemProps) {
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShowDownloadItem({ download }: { download: DownloadContent }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/(downloads)/[tmdbId]",
|
||||
params: { tmdbId: download.media.tmdbId },
|
||||
})
|
||||
}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<View
|
||||
aspectRatio={9 / 14}
|
||||
width={70}
|
||||
maxHeight={180}
|
||||
overflow="hidden"
|
||||
borderRadius="$2"
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: "https://image.tmdb.org/t/p/original//or06FN3Dka5tukK1e9sl16pB3iy.jpg",
|
||||
}}
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</View>
|
||||
<YStack gap="$2">
|
||||
<YStack gap="$1">
|
||||
<Text fontWeight="$bold" ellipse flexGrow={1} fontSize="$5">
|
||||
{download.media.title}
|
||||
</Text>
|
||||
<Text fontSize="$2">
|
||||
{download.downloads.length} Episode
|
||||
{download.downloads.length > 1 ? "s" : ""} |{" "}
|
||||
{formatBytes(
|
||||
download.downloads.reduce(
|
||||
(acc, curr) => acc + curr.fileSize,
|
||||
0,
|
||||
),
|
||||
)}
|
||||
</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
@@ -124,6 +124,7 @@ export default function Item({ data }: { data: ItemData }) {
|
||||
width="100%"
|
||||
overflow="hidden"
|
||||
borderRadius={24}
|
||||
height="$14"
|
||||
>
|
||||
<Image source={{ uri: posterUrl }} width="100%" height="100%" />
|
||||
</View>
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { Linking } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { FontAwesome6, MaterialIcons } from "@expo/vector-icons";
|
||||
import { Circle, View } from "tamagui";
|
||||
@@ -8,20 +7,13 @@ import { DISCORD_LINK, GITHUB_LINK } from "~/constants/core";
|
||||
import { BrandPill } from "../BrandPill";
|
||||
|
||||
export function Header() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
paddingTop={insets.top}
|
||||
alignItems="center"
|
||||
gap="$3"
|
||||
flexDirection="row"
|
||||
>
|
||||
<View alignItems="center" gap="$3" flexDirection="row">
|
||||
<BrandPill />
|
||||
|
||||
<Circle
|
||||
backgroundColor="$pillBackground"
|
||||
size="$4.5"
|
||||
size="$3.5"
|
||||
pressStyle={{
|
||||
opacity: 1,
|
||||
scale: 1.05,
|
||||
@@ -33,11 +25,11 @@ export function Header() {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
||||
}
|
||||
>
|
||||
<MaterialIcons name="discord" size={32} color="white" />
|
||||
<MaterialIcons name="discord" size={28} color="white" />
|
||||
</Circle>
|
||||
<Circle
|
||||
backgroundColor="$pillBackground"
|
||||
size="$4.5"
|
||||
size="$3.5"
|
||||
pressStyle={{
|
||||
opacity: 1,
|
||||
scale: 1.05,
|
||||
@@ -49,7 +41,7 @@ export function Header() {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
||||
}
|
||||
>
|
||||
<FontAwesome6 name="github" size={32} color="white" />
|
||||
<FontAwesome6 name="github" size={28} color="white" />
|
||||
</Circle>
|
||||
</View>
|
||||
);
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ScrollView } from "tamagui";
|
||||
import { LinearGradient } from "tamagui/linear-gradient";
|
||||
|
||||
@@ -7,6 +8,7 @@ interface Props {
|
||||
children?: React.ReactNode;
|
||||
onScrollBeginDrag?: () => void;
|
||||
onMomentumScrollEnd?: () => void;
|
||||
showHeader?: boolean;
|
||||
scrollEnabled?: boolean;
|
||||
keyboardDismissMode?: "none" | "on-drag" | "interactive";
|
||||
keyboardShouldPersistTaps?: "always" | "never" | "handled";
|
||||
@@ -17,11 +19,14 @@ export default function ScreenLayout({
|
||||
children,
|
||||
onScrollBeginDrag,
|
||||
onMomentumScrollEnd,
|
||||
showHeader = true,
|
||||
scrollEnabled,
|
||||
keyboardDismissMode,
|
||||
keyboardShouldPersistTaps,
|
||||
contentContainerStyle,
|
||||
}: Props) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<LinearGradient
|
||||
flex={1}
|
||||
@@ -38,8 +43,9 @@ export default function ScreenLayout({
|
||||
start={[0, 0]}
|
||||
end={[1, 1]}
|
||||
flexGrow={1}
|
||||
paddingTop={showHeader ? insets.top : insets.top + 50}
|
||||
>
|
||||
<Header />
|
||||
{showHeader && <Header />}
|
||||
<ScrollView
|
||||
onScrollBeginDrag={onScrollBeginDrag}
|
||||
onMomentumScrollEnd={onMomentumScrollEnd}
|
||||
|
@@ -4,10 +4,7 @@ import { usePlayerStore } from "~/stores/player/store";
|
||||
import { BrandPill } from "../BrandPill";
|
||||
import { BackButton } from "./BackButton";
|
||||
import { Controls } from "./Controls";
|
||||
|
||||
const mapSeasonAndEpisodeNumberToText = (season: number, episode: number) => {
|
||||
return `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`;
|
||||
};
|
||||
import { mapSeasonAndEpisodeNumberToText } from "./utils";
|
||||
|
||||
export const Header = () => {
|
||||
const isIdle = usePlayerStore((state) => state.interface.isIdle);
|
||||
|
@@ -16,3 +16,10 @@ export const mapMillisecondsToTime = (milliseconds: number): string => {
|
||||
|
||||
return formattedTime;
|
||||
};
|
||||
|
||||
export const mapSeasonAndEpisodeNumberToText = (
|
||||
season: number,
|
||||
episode: number,
|
||||
) => {
|
||||
return `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
@@ -404,19 +404,18 @@ export const useDownloadManager = () => {
|
||||
media,
|
||||
};
|
||||
|
||||
const newDownloadContent = existingDownload
|
||||
? {
|
||||
...existingDownload,
|
||||
downloads: [newDownload, ...existingDownload.downloads],
|
||||
}
|
||||
: {
|
||||
media,
|
||||
downloads: [newDownload],
|
||||
};
|
||||
|
||||
setDownloads((prev) => {
|
||||
return [...prev, newDownloadContent];
|
||||
});
|
||||
if (existingDownload) {
|
||||
existingDownload.downloads.push(newDownload);
|
||||
setDownloads((prev) => {
|
||||
return prev.map((d) =>
|
||||
d.media.tmdbId === media.tmdbId ? existingDownload : d,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
setDownloads((prev) => {
|
||||
return [...prev, { media, downloads: [newDownload] }];
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "mp4") {
|
||||
const asset = await downloadMP4(url, newDownload, headers ?? {});
|
||||
|
Reference in New Issue
Block a user