forked from Ninjalama/streamyfin_mirror
Compare commits
2 Commits
chore/expo
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad751fd2c8 | ||
|
|
42e66b39cc |
3
app.json
3
app.json
@@ -130,7 +130,6 @@
|
||||
},
|
||||
"updates": {
|
||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||
},
|
||||
"newArchEnabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
45
app/(auth)/trailer/page.tsx
Normal file
45
app/(auth)/trailer/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useGlobalSearchParams } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Dimensions, View } from "react-native";
|
||||
import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useGlobalSearchParams();
|
||||
|
||||
const { url } = searchParams as { url: string };
|
||||
|
||||
const videoId = useMemo(() => {
|
||||
return url.split("v=")[1];
|
||||
}, [url]);
|
||||
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
const onStateChange = useCallback((state: PLAYER_STATES) => {
|
||||
if (state === "ended") {
|
||||
setPlaying(false);
|
||||
Alert.alert("video has finished playing!");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const togglePlaying = useCallback(() => {
|
||||
setPlaying((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
togglePlaying();
|
||||
}, []);
|
||||
|
||||
const screenWidth = Dimensions.get("screen").width;
|
||||
|
||||
return (
|
||||
<View className="flex flex-col bg-black items-center justify-center h-full">
|
||||
<YoutubePlayer
|
||||
height={300}
|
||||
play={playing}
|
||||
videoId={videoId}
|
||||
onChangeState={onStateChange}
|
||||
width={screenWidth}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { orientationAtom } from "@/utils/atoms/orientation";
|
||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||
import { LogProvider, writeToLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { formatItemName, storage } from "@/utils/mmkv";
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
@@ -319,7 +319,7 @@ function Layout() {
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style="light" hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
@@ -336,6 +336,14 @@ function Layout() {
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/trailer/page"
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "modal",
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
@@ -377,18 +385,15 @@ function Layout() {
|
||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
let items: BaseItemDto[] = downloadedItems
|
||||
let items: { [key: string]: BaseItemDto } = downloadedItems
|
||||
? JSON.parse(downloadedItems)
|
||||
: [];
|
||||
: {};
|
||||
|
||||
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
||||
if (existingItemIndex !== -1) {
|
||||
items[existingItemIndex] = item;
|
||||
} else {
|
||||
items.push(item);
|
||||
if (item.Id) {
|
||||
item.Path = `${FileSystem.documentDirectory}${formatItemName(item)}.mp4`;
|
||||
items[item.Id] = item;
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
}
|
||||
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
||||
console.error("Failed to save downloaded item information:", error);
|
||||
|
||||
10
components/__tests__/ThemedText-test.tsx
Normal file
10
components/__tests__/ThemedText-test.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
|
||||
import { ThemedText } from '../ThemedText';
|
||||
|
||||
it(`renders correctly`, () => {
|
||||
const tree = renderer.create(<ThemedText>Snapshot test!</ThemedText>).toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
24
components/__tests__/__snapshots__/ThemedText-test.tsx.snap
Normal file
24
components/__tests__/__snapshots__/ThemedText-test.tsx.snap
Normal file
@@ -0,0 +1,24 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<Text
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#11181C",
|
||||
},
|
||||
{
|
||||
"fontSize": 16,
|
||||
"lineHeight": 24,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
Snapshot test!
|
||||
</Text>
|
||||
`;
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
hasPermission,
|
||||
Permission,
|
||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import React, { PropsWithChildren, useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
import {useRouter, useSegments} from "expo-router";
|
||||
import React, {PropsWithChildren, useCallback, useMemo} from "react";
|
||||
import {TouchableOpacity, TouchableOpacityProps} from "react-native";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
result: MovieResult | TvResult;
|
||||
@@ -27,49 +26,78 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr()
|
||||
|
||||
const from = segments[2];
|
||||
|
||||
const autoApprove = useMemo(() => {
|
||||
return (
|
||||
jellyseerrUser &&
|
||||
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
|
||||
type: "or",
|
||||
})
|
||||
);
|
||||
}, [jellyseerrApi, jellyseerrUser]);
|
||||
return jellyseerrUser && hasPermission(
|
||||
Permission.AUTO_APPROVE,
|
||||
jellyseerrUser.permissions,
|
||||
{type: 'or'}
|
||||
)
|
||||
}, [jellyseerrApi, jellyseerrUser])
|
||||
|
||||
const request = useCallback(
|
||||
() =>
|
||||
const request = useCallback(() =>
|
||||
requestMedia(mediaTitle, {
|
||||
mediaId: result.id,
|
||||
mediaType: result.mediaType,
|
||||
}),
|
||||
mediaType: result.mediaType
|
||||
}
|
||||
),
|
||||
[jellyseerrApi, result]
|
||||
);
|
||||
)
|
||||
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||
params: {
|
||||
...result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
// @ts-expect-error
|
||||
canRequest,
|
||||
posterSrc,
|
||||
},
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
// @ts-ignore
|
||||
router.push({pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, params: {...result, mediaTitle, releaseYear, canRequest, posterSrc}});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
avoidCollisions
|
||||
alignOffset={0}
|
||||
collisionPadding={0}
|
||||
loop={false}
|
||||
key={"content"}
|
||||
>
|
||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
||||
{canRequest && result.mediaType === MediaType.MOVIE && (
|
||||
<ContextMenu.Item
|
||||
key="item-1"
|
||||
onSelect={() => {
|
||||
if (autoApprove) {
|
||||
request()
|
||||
}
|
||||
}}
|
||||
shouldDismissMenuOnSelect
|
||||
>
|
||||
<ContextMenu.ItemTitle key="item-1-title">Request</ContextMenu.ItemTitle>
|
||||
<ContextMenu.ItemIcon
|
||||
ios={{
|
||||
name: "arrow.down.to.line",
|
||||
pointSize: 18,
|
||||
weight: "semibold",
|
||||
scale: "medium",
|
||||
hierarchicalColor: {
|
||||
dark: "purple",
|
||||
light: "purple",
|
||||
},
|
||||
}}
|
||||
androidIconName="download"
|
||||
/>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
@@ -15,6 +16,8 @@ export const itemRouter = (
|
||||
item: BaseItemDto | BaseItemPerson,
|
||||
from: string
|
||||
) => {
|
||||
console.log(item.Type, item?.CollectionType);
|
||||
|
||||
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
||||
return `/(auth)/(tabs)/${from}/livetv`;
|
||||
}
|
||||
@@ -77,15 +80,78 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
from === "(favorites)"
|
||||
)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
const url = itemRouter(item, from);
|
||||
// @ts-ignore
|
||||
router.push(url);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
const url = itemRouter(item, from);
|
||||
// @ts-ignore
|
||||
router.push(url);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
avoidCollisions
|
||||
alignOffset={0}
|
||||
collisionPadding={0}
|
||||
loop={false}
|
||||
key={"content"}
|
||||
>
|
||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
||||
<ContextMenu.Item
|
||||
key="item-1"
|
||||
onSelect={() => {
|
||||
markAsPlayedStatus(true);
|
||||
}}
|
||||
shouldDismissMenuOnSelect
|
||||
>
|
||||
<ContextMenu.ItemTitle key="item-1-title">
|
||||
Mark as watched
|
||||
</ContextMenu.ItemTitle>
|
||||
<ContextMenu.ItemIcon
|
||||
ios={{
|
||||
name: "checkmark.circle", // Changed to "checkmark.circle" which represents "watched"
|
||||
pointSize: 18,
|
||||
weight: "semibold",
|
||||
scale: "medium",
|
||||
hierarchicalColor: {
|
||||
dark: "green", // Changed to green for "watched"
|
||||
light: "green",
|
||||
},
|
||||
}}
|
||||
androidIconName="checkmark-circle"
|
||||
></ContextMenu.ItemIcon>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
key="item-2"
|
||||
onSelect={() => {
|
||||
markAsPlayedStatus(false);
|
||||
}}
|
||||
shouldDismissMenuOnSelect
|
||||
destructive
|
||||
>
|
||||
<ContextMenu.ItemTitle key="item-2-title">
|
||||
Mark as not watched
|
||||
</ContextMenu.ItemTitle>
|
||||
<ContextMenu.ItemIcon
|
||||
ios={{
|
||||
name: "eye.slash", // Changed to "eye.slash" which represents "not watched"
|
||||
pointSize: 18, // Adjusted for better visibility
|
||||
weight: "semibold",
|
||||
scale: "medium",
|
||||
hierarchicalColor: {
|
||||
dark: "red", // Changed to red for "not watched"
|
||||
light: "red",
|
||||
},
|
||||
// Removed paletteColors as it's not necessary in this case
|
||||
}}
|
||||
androidIconName="eye-slash"
|
||||
></ContextMenu.ItemIcon>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Input } from "../common/Input";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { Loader } from "../Loader";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Button } from "../Button";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useAtom } from "jotai";
|
||||
import { toast } from "sonner-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "../Button";
|
||||
import { Input } from "../common/Input";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const JellyseerrSettings = () => {
|
||||
const {
|
||||
|
||||
@@ -1,59 +1,113 @@
|
||||
import { Alert, View, ViewProps } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { Button } from "../Button";
|
||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useAtom } from "jotai";
|
||||
import Constants from "expo-constants";
|
||||
import Application from "expo-application";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { Alert, View, ViewProps } from "react-native";
|
||||
import { Button } from "../Button";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const openQuickConnectAuthCodeInput = () => {
|
||||
Alert.prompt(
|
||||
"Quick connect",
|
||||
"Enter the quick connect code",
|
||||
async (text) => {
|
||||
if (text) {
|
||||
try {
|
||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||
code: text,
|
||||
userId: user?.Id,
|
||||
});
|
||||
if (res.status === 200) {
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
Alert.alert("Success", "Quick connect authorized");
|
||||
} else {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert("Error", "Invalid code");
|
||||
}
|
||||
} catch (e) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert("Error", "Invalid code");
|
||||
}
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const authorizeQuickConnect = useCallback(async () => {
|
||||
if (quickConnectCode) {
|
||||
try {
|
||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||
code: quickConnectCode,
|
||||
userId: user?.Id,
|
||||
});
|
||||
if (res.status === 200) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
Alert.alert("Success", "Quick connect authorized");
|
||||
setQuickConnectCode(undefined);
|
||||
bottomSheetModalRef?.current?.close();
|
||||
} else {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert("Error", "Invalid code");
|
||||
}
|
||||
} catch (e) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert("Error", "Invalid code");
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [api, user, quickConnectCode]);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup title={"Quick Connect"}>
|
||||
<ListItem
|
||||
onPress={openQuickConnectAuthCodeInput}
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
title="Authorize Quick Connect"
|
||||
textColor="blue"
|
||||
></ListItem>
|
||||
/>
|
||||
</ListGroup>
|
||||
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<View>
|
||||
<Text className="font-bold text-2xl text-neutral-100">
|
||||
Quick Connect
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
|
||||
<BottomSheetTextInput
|
||||
style={{ color: "white" }}
|
||||
clearButtonMode="always"
|
||||
placeholder="Enter the quick connect code..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={quickConnectCode}
|
||||
onChangeText={setQuickConnectCode}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
className="mt-auto"
|
||||
onPress={authorizeQuickConnect}
|
||||
color="purple"
|
||||
>
|
||||
Authorize
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { getFilePathFromItemId } from "@/utils/mmkv";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -17,13 +18,13 @@ export const getDownloadedFileUrl = async (itemId: string): Promise<string> => {
|
||||
}
|
||||
|
||||
const files = await FileSystem.readDirectoryAsync(directory);
|
||||
const path = itemId!;
|
||||
const matchingFile = files.find((file) => file.startsWith(path));
|
||||
const filePath = getFilePathFromItemId(itemId);
|
||||
|
||||
const matchingFile = files.find((file) => file === filePath);
|
||||
|
||||
if (!matchingFile) {
|
||||
throw new Error(`No file found for item ${path}`);
|
||||
throw new Error(`No file found for item ${filePath}`);
|
||||
}
|
||||
|
||||
return `${directory}${matchingFile}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import useDownloadHelper from "@/utils/download";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { JobStatus } from "@/utils/optimize-server";
|
||||
import { formatItemName } from "@/utils/mmkv";
|
||||
|
||||
const createFFmpegCommand = (url: string, output: string) => [
|
||||
"-y", // overwrite output files without asking
|
||||
@@ -53,7 +54,12 @@ export const useRemuxHlsToMp4 = () => {
|
||||
const [settings] = useSettings();
|
||||
const { saveImage } = useImageStorage();
|
||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||
const { saveDownloadedItemInfo, setProcesses, processes, APP_CACHE_DOWNLOAD_DIRECTORY } = useDownload();
|
||||
const {
|
||||
saveDownloadedItemInfo,
|
||||
setProcesses,
|
||||
processes,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
} = useDownload();
|
||||
|
||||
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
||||
await saveSeriesPrimaryImage(item);
|
||||
@@ -73,13 +79,12 @@ export const useRemuxHlsToMp4 = () => {
|
||||
try {
|
||||
console.log("completeCallback");
|
||||
const returnCode = await session.getReturnCode();
|
||||
|
||||
if (returnCode.isValueSuccess()) {
|
||||
const stat = await session.getLastReceivedStatistics();
|
||||
await FileSystem.moveAsync({
|
||||
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
|
||||
to: `${FileSystem.documentDirectory}${item.Id}.mp4`
|
||||
})
|
||||
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
|
||||
to: `${FileSystem.documentDirectory}${formatItemName(item)}.mp4`,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["downloadedItems"],
|
||||
});
|
||||
@@ -131,12 +136,16 @@ export const useRemuxHlsToMp4 = () => {
|
||||
|
||||
const startRemuxing = useCallback(
|
||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
||||
const cacheDir = await FileSystem.getInfoAsync(APP_CACHE_DOWNLOAD_DIRECTORY);
|
||||
const cacheDir = await FileSystem.getInfoAsync(
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY
|
||||
);
|
||||
if (!cacheDir.exists) {
|
||||
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {intermediates: true})
|
||||
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
|
||||
intermediates: true,
|
||||
});
|
||||
}
|
||||
|
||||
const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4`
|
||||
const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4`;
|
||||
|
||||
if (!api) throw new Error("API is not defined");
|
||||
if (!item.Id) throw new Error("Item must have an Id");
|
||||
|
||||
104
package.json
104
package.json
@@ -4,100 +4,102 @@
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"submodule-reload": "git submodule update --init --remote --recursive",
|
||||
"clean": "echo y | expo prebuild --clean",
|
||||
"start": "bun run submodule-reload && expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "bun run submodule-reload && expo run:android",
|
||||
"ios": "bun run submodule-reload && expo run:ios",
|
||||
"web": "bun run submodule-reload && expo start --web",
|
||||
"test": "jest --watchAll",
|
||||
"lint": "expo lint",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bottom-tabs/react-navigation": "0.8.0",
|
||||
"react-native-bottom-tabs": "0.8.0",
|
||||
"@react-navigation/material-top-tabs": "^7.1.0",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@bottom-tabs/react-navigation": "^0.7.1",
|
||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
||||
"@expo/react-native-action-sheet": "^4.1.0",
|
||||
"@expo/vector-icons": "^14.0.4",
|
||||
"@futurejj/react-native-visibility-sensor": "^1.3.5",
|
||||
"@gorhom/bottom-sheet": "^5.0.6",
|
||||
"@gorhom/bottom-sheet": "^4.6.4",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"@kesha-antonov/react-native-background-downloader": "3.2.6",
|
||||
"@kesha-antonov/react-native-background-downloader": "3.1.2",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-native-community/netinfo": "11.3.1",
|
||||
"@react-native-menu/menu": "^1.1.6",
|
||||
"@shopify/flash-list": "1.7.1",
|
||||
"@react-navigation/material-top-tabs": "^6.6.14",
|
||||
"@react-navigation/native": "^6.1.18",
|
||||
"@shopify/flash-list": "1.6.4",
|
||||
"@tanstack/react-query": "^5.59.20",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"add": "^2.0.6",
|
||||
"axios": "^1.7.7",
|
||||
"expo": "^52.0.0",
|
||||
"expo-asset": "~11.0.2",
|
||||
"expo-background-fetch": "~13.0.4",
|
||||
"expo-blur": "~14.0.3",
|
||||
"expo-brightness": "~13.0.3",
|
||||
"expo-build-properties": "~0.13.2",
|
||||
"expo-constants": "~17.0.4",
|
||||
"expo-dev-client": "~5.0.10",
|
||||
"expo-device": "~7.0.2",
|
||||
"expo-font": "~13.0.3",
|
||||
"expo-haptics": "~14.0.1",
|
||||
"expo-image": "~2.0.4",
|
||||
"expo-keep-awake": "~14.0.2",
|
||||
"expo-linear-gradient": "~14.0.2",
|
||||
"expo-linking": "~7.0.4",
|
||||
"expo-network": "~7.0.5",
|
||||
"expo-notifications": "~0.29.12",
|
||||
"expo-router": "~4.0.17",
|
||||
"expo-screen-orientation": "~8.0.4",
|
||||
"expo-sensors": "~14.0.2",
|
||||
"expo-splash-screen": "~0.29.21",
|
||||
"expo-status-bar": "~2.0.1",
|
||||
"expo-system-ui": "~4.0.7",
|
||||
"expo-task-manager": "~12.0.4",
|
||||
"expo-updates": "~0.26.13",
|
||||
"expo-web-browser": "~14.0.2",
|
||||
"expo": "~51.0.39",
|
||||
"expo-asset": "~10.0.10",
|
||||
"expo-background-fetch": "~12.0.1",
|
||||
"expo-blur": "~13.0.2",
|
||||
"expo-brightness": "~12.0.1",
|
||||
"expo-build-properties": "~0.12.5",
|
||||
"expo-constants": "~16.0.2",
|
||||
"expo-dev-client": "~4.0.29",
|
||||
"expo-device": "~6.0.2",
|
||||
"expo-font": "~12.0.10",
|
||||
"expo-haptics": "~13.0.1",
|
||||
"expo-image": "~1.13.0",
|
||||
"expo-keep-awake": "~13.0.2",
|
||||
"expo-linear-gradient": "~13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-network": "~6.0.1",
|
||||
"expo-notifications": "~0.28.19",
|
||||
"expo-router": "~3.5.24",
|
||||
"expo-screen-orientation": "~7.0.5",
|
||||
"expo-sensors": "~13.0.9",
|
||||
"expo-splash-screen": "~0.27.7",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"expo-system-ui": "^3.0.7",
|
||||
"expo-task-manager": "~11.8.2",
|
||||
"expo-updates": "~0.25.27",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"ffmpeg-kit-react-native": "^6.0.2",
|
||||
"install": "^0.13.0",
|
||||
"jotai": "^2.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"nativewind": "^2.0.11",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.6",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-native": "0.74.5",
|
||||
"react-native-awesome-slider": "^2.5.6",
|
||||
"react-native-bottom-tabs": "0.7.1",
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
"react-native-compressor": "^1.9.0",
|
||||
"react-native-country-flag": "^2.0.2",
|
||||
"react-native-device-info": "^14.0.1",
|
||||
"react-native-edge-to-edge": "^1.1.3",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-edge-to-edge": "^1.1.1",
|
||||
"react-native-gesture-handler": "~2.16.1",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-google-cast": "^4.8.3",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^2.5.2",
|
||||
"react-native-ios-utilities": "4.5.3",
|
||||
"react-native-ios-utilities": "^4.5.1",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "6.5.1",
|
||||
"react-native-pager-view": "6.3.0",
|
||||
"react-native-progress": "^5.0.1",
|
||||
"react-native-reanimated": "~3.16.1",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
"react-native-reanimated-carousel": "4.0.0-canary.22",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.4.0",
|
||||
"react-native-svg": "15.8.0",
|
||||
"react-native-safe-area-context": "4.10.5",
|
||||
"react-native-screens": "3.31.1",
|
||||
"react-native-svg": "15.2.0",
|
||||
"react-native-tab-view": "^3.5.2",
|
||||
"react-native-udp": "^4.1.7",
|
||||
"react-native-uitextview": "^1.4.0",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-uuid": "^2.0.2",
|
||||
"react-native-video": "^6.7.0",
|
||||
"react-native-volume-manager": "^1.10.0",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-webview": "13.12.5",
|
||||
"react-native-webview": "13.8.6",
|
||||
"react-native-youtube-iframe": "^2.3.0",
|
||||
"sonner-native": "^0.14.2",
|
||||
"tailwindcss": "3.3.2",
|
||||
"use-debounce": "^10.0.4",
|
||||
@@ -108,8 +110,10 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react": "~18.2.79",
|
||||
"@types/react-test-renderer": "^18.0.7",
|
||||
"jest": "^29.2.1",
|
||||
"jest-expo": "~51.0.4",
|
||||
"patch-package": "^8.0.0",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"react-test-renderer": "18.2.0",
|
||||
|
||||
@@ -19,14 +19,7 @@ import {
|
||||
download,
|
||||
setConfig,
|
||||
} from "@kesha-antonov/react-native-background-downloader";
|
||||
import MMKV from "react-native-mmkv";
|
||||
import {
|
||||
focusManager,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -45,7 +38,7 @@ import { apiAtom } from "./JellyfinProvider";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import useImageStorage from "@/hooks/useImageStorage";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { formatItemName, saveItemMapping, storage } from "@/utils/mmkv";
|
||||
import useDownloadHelper from "@/utils/download";
|
||||
import { FileInfo } from "expo-file-system";
|
||||
import * as Haptics from "expo-haptics";
|
||||
@@ -54,8 +47,11 @@ import * as Application from "expo-application";
|
||||
export type DownloadedItem = {
|
||||
item: Partial<BaseItemDto>;
|
||||
mediaSource: MediaSourceInfo;
|
||||
fileSize: number;
|
||||
};
|
||||
|
||||
export type DownloadedItem2 = {};
|
||||
|
||||
export const processesAtom = atom<JobStatus[]>([]);
|
||||
|
||||
function onAppStateChange(status: AppStateStatus) {
|
||||
@@ -512,8 +508,10 @@ function useDownloadProvider() {
|
||||
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
if (downloadedItems) {
|
||||
let items = JSON.parse(downloadedItems) as DownloadedItem[];
|
||||
items = items.filter((item) => item.item.Id !== id);
|
||||
let items: { [key: string]: BaseItemDto } = downloadedItems
|
||||
? JSON.parse(downloadedItems)
|
||||
: {};
|
||||
delete items[id];
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
}
|
||||
|
||||
@@ -586,7 +584,7 @@ function useDownloadProvider() {
|
||||
const appSizeUsage = useMemo(async () => {
|
||||
const sizes: number[] =
|
||||
downloadedFiles?.map((d) => {
|
||||
return getDownloadedItemSize(d.item.Id!!);
|
||||
return d.fileSize;
|
||||
}) || [];
|
||||
|
||||
await forEveryDocumentDirFile(
|
||||
@@ -608,8 +606,10 @@ function useDownloadProvider() {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
if (downloadedItems) {
|
||||
const items: DownloadedItem[] = JSON.parse(downloadedItems);
|
||||
const item = items.find((i) => i.item.Id === itemId);
|
||||
const items: { [key: string]: BaseItemDto } = downloadedItems
|
||||
? JSON.parse(downloadedItems)
|
||||
: {};
|
||||
const item = items[itemId] as DownloadedItem;
|
||||
return item || null;
|
||||
}
|
||||
return null;
|
||||
@@ -623,7 +623,7 @@ function useDownloadProvider() {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
if (downloadedItems) {
|
||||
return JSON.parse(downloadedItems) as DownloadedItem[];
|
||||
return Object.values(JSON.parse(downloadedItems)) as DownloadedItem[];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
@@ -636,11 +636,11 @@ function useDownloadProvider() {
|
||||
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
let items: DownloadedItem[] = downloadedItems
|
||||
const items: { [key: string]: BaseItemDto } = downloadedItems
|
||||
? JSON.parse(downloadedItems)
|
||||
: [];
|
||||
: {};
|
||||
|
||||
const existingItemIndex = items.findIndex((i) => i.item.Id === item.Id);
|
||||
const chosenItem = items[item.Id!] || {};
|
||||
|
||||
const data = getDownloadItemInfoFromDiskTmp(item.Id!);
|
||||
|
||||
@@ -651,12 +651,6 @@ function useDownloadProvider() {
|
||||
|
||||
const newItem = { item, mediaSource: data.mediaSource };
|
||||
|
||||
if (existingItemIndex !== -1) {
|
||||
items[existingItemIndex] = newItem;
|
||||
} else {
|
||||
items.push(newItem);
|
||||
}
|
||||
|
||||
deleteDownloadItemInfoFromDiskTmp(item.Id!);
|
||||
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
|
||||
export const storage = new MMKV();
|
||||
const storage = new MMKV();
|
||||
|
||||
const saveItemMapping = (itemId: string | undefined, fileName: string) => {
|
||||
if (!itemId) return;
|
||||
storage.set(itemId, fileName);
|
||||
};
|
||||
|
||||
const getFilePathFromItemId = (itemId: string): string | undefined => {
|
||||
return storage.getString(itemId);
|
||||
};
|
||||
|
||||
const formatItemName = (item: BaseItemDto) => {
|
||||
if (item.Type === "Episode") {
|
||||
const formattedParentIndexNumber = (item.ParentIndexNumber ?? 0)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const formattedIndexNumber = (item.IndexNumber ?? 0)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
|
||||
const formattedString = `S${formattedParentIndexNumber}E${formattedIndexNumber}`;
|
||||
return `${item.SeriesName} - ${formattedString} - ${item.Name}`;
|
||||
}
|
||||
return item.Name;
|
||||
};
|
||||
|
||||
export { saveItemMapping, getFilePathFromItemId, storage, formatItemName };
|
||||
|
||||
Reference in New Issue
Block a user