add episode download section

This commit is contained in:
Jorrin
2024-04-08 21:22:09 +02:00
parent ae5505da7f
commit 96b00064c6
10 changed files with 263 additions and 105 deletions

View 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>
);
}

View 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,
}}
/>
);
}

View File

@@ -7,7 +7,7 @@ import { ScrollView, useTheme, YStack } from "tamagui";
import type { ScrapeMedia } from "@movie-web/provider-utils"; 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 ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button"; import { MWButton } from "~/components/ui/Button";
import { useDownloadManager } from "~/hooks/useDownloadManager"; import { useDownloadManager } from "~/hooks/useDownloadManager";
@@ -15,15 +15,69 @@ import { PlayerStatus } from "~/stores/player/slices/interface";
import { usePlayerStore } from "~/stores/player/store"; import { usePlayerStore } from "~/stores/player/store";
import { useDownloadHistoryStore } from "~/stores/settings"; 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 { 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 downloads = useDownloadHistoryStore((state) => state.downloads);
const resetVideo = usePlayerStore((state) => state.resetVideo); const resetVideo = usePlayerStore((state) => state.resetVideo);
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc); const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile); const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile);
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus); const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
const router = useRouter(); const router = useRouter();
const theme = useTheme();
useFocusEffect( useFocusEffect(
React.useCallback(() => { 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 ( return (
<ScreenLayout> <ScreenLayout>
<YStack gap={2} style={{ padding: 10 }}> <YStack gap={2} style={{ padding: 10 }}>
<MWButton <TestDownloadButton
type="secondary" media={exampleMovieMedia}
backgroundColor="$sheetItemBackground" type="mp4"
icon={ url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
<MaterialCommunityIcons />
name="download" <TestDownloadButton
size={24} media={getExampleShowMedia(1, 1)}
color={theme.buttonSecondaryText.val} type="mp4"
/> url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
} />
onPress={async () => { <TestDownloadButton
await startDownload( media={getExampleShowMedia(1, 2)}
"https://samplelib.com/lib/preview/mp4/sample-5s.mp4", type="mp4"
"mp4", url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
exampleShowMedia, />
).catch(console.error); <TestDownloadButton
}} media={getExampleShowMedia(1, 1)}
> type="hls"
test download (mp4) url="http://sample.vodobox.com/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8"
</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>
</YStack> </YStack>
<ScrollView <ScrollView
contentContainerStyle={{ contentContainerStyle={{
gap: "$4", gap: "$4",
}} }}
> >
{/* TODO: Differentiate movies/shows, shows in new page */} {downloads.map((download) => {
{downloads if (download.downloads.length === 0) return null;
.map((item) => item.downloads) if (download.media.type === "movie") {
.flat() return (
.map((item) => ( <DownloadItem
<DownloadItem key={download.media.tmdbId}
key={item.id} item={download.downloads[0]!}
item={item} onPress={() => handlePress(download.downloads[0]!.localPath)}
onPress={() => handlePress(item.localPath)} />
/> );
))} } else {
return (
<ShowDownloadItem
key={download.media.tmdbId}
download={download}
/>
);
}
})}
</ScrollView> </ScrollView>
</ScreenLayout> </ScreenLayout>
); );

View File

@@ -3,10 +3,12 @@ import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-vi
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 { useRouter } from "expo-router";
import { Image, Text, View, XStack, YStack } from "tamagui"; 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 { useDownloadManager } from "~/hooks/useDownloadManager";
import { mapSeasonAndEpisodeNumberToText } from "./player/utils";
import { MWProgress } from "./ui/Progress"; import { MWProgress } from "./ui/Progress";
import { FlashingText } from "./ui/Text"; import { FlashingText } from "./ui/Text";
@@ -101,6 +103,11 @@ export function DownloadItem(props: DownloadItemProps) {
<YStack gap="$2"> <YStack gap="$2">
<XStack gap="$6" maxWidth="65%"> <XStack gap="$6" maxWidth="65%">
<Text fontWeight="$bold" ellipse flexGrow={1}> <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} {props.item.media.title}
</Text> </Text>
{props.item.type !== "hls" && ( {props.item.type !== "hls" && (
@@ -136,3 +143,54 @@ export function DownloadItem(props: DownloadItemProps) {
</ContextMenu> </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>
);
}

View File

@@ -124,6 +124,7 @@ export default function Item({ data }: { data: ItemData }) {
width="100%" width="100%"
overflow="hidden" overflow="hidden"
borderRadius={24} borderRadius={24}
height="$14"
> >
<Image source={{ uri: posterUrl }} width="100%" height="100%" /> <Image source={{ uri: posterUrl }} width="100%" height="100%" />
</View> </View>

View File

@@ -1,5 +1,4 @@
import { Linking } from "react-native"; import { Linking } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { FontAwesome6, MaterialIcons } from "@expo/vector-icons"; import { FontAwesome6, MaterialIcons } from "@expo/vector-icons";
import { Circle, View } from "tamagui"; import { Circle, View } from "tamagui";
@@ -8,20 +7,13 @@ import { DISCORD_LINK, GITHUB_LINK } from "~/constants/core";
import { BrandPill } from "../BrandPill"; import { BrandPill } from "../BrandPill";
export function Header() { export function Header() {
const insets = useSafeAreaInsets();
return ( return (
<View <View alignItems="center" gap="$3" flexDirection="row">
paddingTop={insets.top}
alignItems="center"
gap="$3"
flexDirection="row"
>
<BrandPill /> <BrandPill />
<Circle <Circle
backgroundColor="$pillBackground" backgroundColor="$pillBackground"
size="$4.5" size="$3.5"
pressStyle={{ pressStyle={{
opacity: 1, opacity: 1,
scale: 1.05, scale: 1.05,
@@ -33,11 +25,11 @@ export function Header() {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
} }
> >
<MaterialIcons name="discord" size={32} color="white" /> <MaterialIcons name="discord" size={28} color="white" />
</Circle> </Circle>
<Circle <Circle
backgroundColor="$pillBackground" backgroundColor="$pillBackground"
size="$4.5" size="$3.5"
pressStyle={{ pressStyle={{
opacity: 1, opacity: 1,
scale: 1.05, scale: 1.05,
@@ -49,7 +41,7 @@ export function Header() {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
} }
> >
<FontAwesome6 name="github" size={32} color="white" /> <FontAwesome6 name="github" size={28} color="white" />
</Circle> </Circle>
</View> </View>
); );

View File

@@ -1,3 +1,4 @@
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScrollView } from "tamagui"; import { ScrollView } from "tamagui";
import { LinearGradient } from "tamagui/linear-gradient"; import { LinearGradient } from "tamagui/linear-gradient";
@@ -7,6 +8,7 @@ interface Props {
children?: React.ReactNode; children?: React.ReactNode;
onScrollBeginDrag?: () => void; onScrollBeginDrag?: () => void;
onMomentumScrollEnd?: () => void; onMomentumScrollEnd?: () => void;
showHeader?: boolean;
scrollEnabled?: boolean; scrollEnabled?: boolean;
keyboardDismissMode?: "none" | "on-drag" | "interactive"; keyboardDismissMode?: "none" | "on-drag" | "interactive";
keyboardShouldPersistTaps?: "always" | "never" | "handled"; keyboardShouldPersistTaps?: "always" | "never" | "handled";
@@ -17,11 +19,14 @@ export default function ScreenLayout({
children, children,
onScrollBeginDrag, onScrollBeginDrag,
onMomentumScrollEnd, onMomentumScrollEnd,
showHeader = true,
scrollEnabled, scrollEnabled,
keyboardDismissMode, keyboardDismissMode,
keyboardShouldPersistTaps, keyboardShouldPersistTaps,
contentContainerStyle, contentContainerStyle,
}: Props) { }: Props) {
const insets = useSafeAreaInsets();
return ( return (
<LinearGradient <LinearGradient
flex={1} flex={1}
@@ -38,8 +43,9 @@ export default function ScreenLayout({
start={[0, 0]} start={[0, 0]}
end={[1, 1]} end={[1, 1]}
flexGrow={1} flexGrow={1}
paddingTop={showHeader ? insets.top : insets.top + 50}
> >
<Header /> {showHeader && <Header />}
<ScrollView <ScrollView
onScrollBeginDrag={onScrollBeginDrag} onScrollBeginDrag={onScrollBeginDrag}
onMomentumScrollEnd={onMomentumScrollEnd} onMomentumScrollEnd={onMomentumScrollEnd}

View File

@@ -4,10 +4,7 @@ import { usePlayerStore } from "~/stores/player/store";
import { BrandPill } from "../BrandPill"; import { BrandPill } from "../BrandPill";
import { BackButton } from "./BackButton"; import { BackButton } from "./BackButton";
import { Controls } from "./Controls"; import { Controls } from "./Controls";
import { mapSeasonAndEpisodeNumberToText } from "./utils";
const mapSeasonAndEpisodeNumberToText = (season: number, episode: number) => {
return `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`;
};
export const Header = () => { export const Header = () => {
const isIdle = usePlayerStore((state) => state.interface.isIdle); const isIdle = usePlayerStore((state) => state.interface.isIdle);

View File

@@ -16,3 +16,10 @@ export const mapMillisecondsToTime = (milliseconds: number): string => {
return formattedTime; return formattedTime;
}; };
export const mapSeasonAndEpisodeNumberToText = (
season: number,
episode: number,
) => {
return `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`;
};

View File

@@ -404,19 +404,18 @@ export const useDownloadManager = () => {
media, media,
}; };
const newDownloadContent = existingDownload if (existingDownload) {
? { existingDownload.downloads.push(newDownload);
...existingDownload, setDownloads((prev) => {
downloads: [newDownload, ...existingDownload.downloads], return prev.map((d) =>
} d.media.tmdbId === media.tmdbId ? existingDownload : d,
: { );
media, });
downloads: [newDownload], } else {
}; setDownloads((prev) => {
return [...prev, { media, downloads: [newDownload] }];
setDownloads((prev) => { });
return [...prev, newDownloadContent]; }
});
if (type === "mp4") { if (type === "mp4") {
const asset = await downloadMP4(url, newDownload, headers ?? {}); const asset = await downloadMP4(url, newDownload, headers ?? {});