Compare commits

..

2 Commits

Author SHA1 Message Date
Fredrik Burmester
6fb8a5fca2 chore 2024-08-11 10:39:57 +02:00
Fredrik Burmester
86dfcc6b7f chore 2024-08-11 09:59:55 +02:00
33 changed files with 213 additions and 951 deletions

1
.gitignore vendored
View File

@@ -26,4 +26,3 @@ Streamyfin.app
/android
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json

View File

@@ -21,20 +21,14 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them.
## 🛠️ Beta testing (iOS/Android)
## 🛠️ TestFlight (pending review)
## TestFlight
Soon iOS users can test Streamyfin in beta via TestFlight. To join the beta program, click the link below.
<a href="https://testflight.apple.com/join/CWBaAAK2">
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
</a>
## Play Store Open Beta
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin">
<img height=75 alt="Get the beta on Google Play" src="./assets/en_badge_web_generic.png"/>
</a>
## 🚀 Getting Started
### Prerequisites

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.2.0",
"version": "0.0.6",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -15,6 +15,7 @@
"jsEngine": "hermes",
"assetBundlePatterns": ["**/*"],
"ios": {
"userInterfaceStyle": "dark",
"infoPlist": {
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
"NSMicrophoneUsageDescription": "The app needs access to your microphone."
@@ -24,20 +25,12 @@
},
"android": {
"jsEngine": "jsc",
"androidNavigationBar": {
"visible": true,
"barStyle": "dark-content",
"backgroundColor": "#000000"
},
"userInterfaceStyle": "light",
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
"foregroundImage": "./assets/images/icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.fredrikburmester.streamyfin",
"permissions": [
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
],
"versionCode": 6
"package": "com.fredrikburmester.streamyfin"
},
"web": {
"bundler": "metro",
@@ -48,13 +41,6 @@
"expo-router",
"expo-font",
"react-native-compressor",
"@config-plugins/ffmpeg-kit-react-native",
[
"react-native-google-cast",
{
"useDefaultExpandedMediaControls": true
}
],
[
"react-native-video",
{
@@ -62,7 +48,7 @@
"androidExtensions": {
"useExoplayerRtsp": false,
"useExoplayerSmoothStreaming": false,
"useExoplayerHls": true,
"useExoplayerHls": false,
"useExoplayerDash": false
}
}
@@ -70,9 +56,7 @@
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "14.0"
},
"ios": { "deploymentTarget": "14.0" },
"android": {
"minSdkVersion": 24,
"packagingOptions": {
@@ -95,12 +79,6 @@
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
}
},
"owner": "fredrikburmester",
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
}
"owner": "fredrikburmester"
}
}

View File

@@ -1,19 +1,12 @@
import { router, Tabs } from "expo-router";
import React, { useEffect } from "react";
import * as NavigationBar from "expo-navigation-bar";
import React from "react";
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import { Colors } from "@/constants/Colors";
import { Platform, TouchableOpacity } from "react-native";
import { TouchableOpacity } from "react-native";
import { Feather } from "@expo/vector-icons";
export default function TabLayout() {
useEffect(() => {
if (Platform.OS === "android") {
NavigationBar.setBackgroundColorAsync("#121212");
NavigationBar.setBorderColorAsync("#121212");
}
}, []);
return (
<Tabs
screenOptions={{

View File

@@ -241,7 +241,7 @@ export default function index() {
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
>
<View className="flex flex-col pt-4 pb-24 gap-y-4">
<View className="flex flex-col py-4 gap-y-4">
<View>
<Text className="px-4 text-2xl font-bold mb-2">
Continue Watching

View File

@@ -68,7 +68,7 @@ export default function search() {
return (
<ScrollView keyboardDismissMode="on-drag">
<View className="flex flex-col pt-2 pb-20">
<View className="flex flex-col py-2">
<View className="mb-4 px-4">
<Input
autoCorrect={false}

View File

@@ -15,7 +15,6 @@ import { useAtom } from "jotai";
import { runningProcesses } from "@/utils/atoms/downloads";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { FFmpegKit } from "ffmpeg-kit-react-native";
const downloads: React.FC = () => {
const { data: downloadedFiles, isLoading } = useQuery({
@@ -89,7 +88,6 @@ const downloads: React.FC = () => {
</View>
<TouchableOpacity
onPress={() => {
FFmpegKit.cancel();
setProcess(null);
}}
>

View File

@@ -11,7 +11,7 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import {
ActivityIndicator,
ScrollView,
@@ -22,29 +22,16 @@ import { ParallaxScrollView } from "../../../../components/ParallaxPage";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { PlayButton } from "@/components/PlayButton";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { useCastDevice } from "react-native-google-cast";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios12 from "@/utils/profiles/ios12";
import { currentlyPlayingItemAtom } from "@/components/CurrentlyPlayingBar";
const page: React.FC = () => {
const local = useLocalSearchParams();
const { id } = local as { id: string };
const [playbackURL, setPlaybackURL] = useState<string | null>(null);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
const { data: item, isLoading: l1 } = useQuery({
queryKey: ["item", id],
queryFn: async () =>
@@ -73,52 +60,6 @@ const page: React.FC = () => {
[item],
);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", item?.Id],
queryFn: async () => {
if (!api || !user?.Id || !item?.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: item?.Id,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!item?.Id && !!api && !!user?.Id,
staleTime: 0,
});
const { data: playbackUrl } = useQuery({
queryKey: ["playbackUrl", item?.Id, maxBitrate, castDevice],
queryFn: async () => {
if (!api || !user?.Id || !sessionData) return null;
const url = await getStreamUrl({
api,
userId: user.Id,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate.value,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
});
return url;
},
enabled: !!sessionData,
staleTime: 0,
});
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
const onPressPlay = useCallback(() => {
if (!playbackUrl || !item) return;
setCp({
item,
playbackUrl,
});
}, [playbackUrl, item]);
if (l1)
return (
<View className="justify-center items-center h-full">
@@ -210,19 +151,20 @@ const page: React.FC = () => {
</View>
<View className="flex flex-row justify-between items-center w-full my-4">
{playbackUrl && (
<DownloadItem item={item} playbackUrl={playbackUrl} />
{playbackURL && (
<DownloadItem item={item} playbackURL={playbackURL} />
)}
<Chromecast />
// <Chromecast />
</View>
<Text>{item.Overview}</Text>
</View>
<View className="flex flex-col p-4">
<BitrateSelector
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
<VideoPlayer
itemId={item.Id}
onChangePlaybackURL={(val) => {
setPlaybackURL(val);
}}
/>
<PlayButton item={item} chromecastReady={false} onPress={onPressPlay} />
</View>
<ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 ">

View File

@@ -10,21 +10,12 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import { View } from "react-native";
const page: React.FC = () => {
const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as {
id: string;
seasonIndex: string;
};
useEffect(() => {
if (seriesId) {
console.log("seasonIndex", seasonIndex);
}
}, [seriesId]);
const { id: seriesId } = params as { id: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -93,7 +84,7 @@ const page: React.FC = () => {
</>
}
>
<View className="flex flex-col pt-4 pb-24">
<View className="flex flex-col pt-4 pb-12">
<View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>

View File

@@ -64,17 +64,19 @@ export default function settings() {
<Text className="font-bold text-2xl">Logs</Text>
<View className="flex flex-col space-y-2">
{logs?.map((log, index) => (
<View key={index} className="bg-neutral-900 rounded-xl p-3">
<View
key={index}
className="bg-neutral-800 border border-neutral-900 rounded p-2"
>
<Text
className={`
mb-1
${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"}
`}
>
{log.level}
</Text>
<Text className="text-xs">{log.message}</Text>
<Text>{log.message}</Text>
</View>
))}
{logs?.length === 0 && (

View File

@@ -1,24 +1,17 @@
import { JellyfinProvider } from "@/providers/JellyfinProvider";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useFonts } from "expo-font";
import * as NavigationBar from "expo-navigation-bar";
import { Stack, router } from "expo-router";
import { router, Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef } from "react";
import { Platform, TouchableOpacity } from "react-native";
import "react-native-reanimated";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider as JotaiProvider } from "jotai";
import { JellyfinProvider } from "@/providers/JellyfinProvider";
import { TouchableOpacity } from "react-native";
import Feather from "@expo/vector-icons/Feather";
import { StatusBar } from "expo-status-bar";
import { Colors } from "@/constants/Colors";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import Video from "react-native-video";
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@@ -46,8 +39,6 @@ export default function RootLayout() {
}),
);
const insets = useSafeAreaInsets();
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
@@ -62,9 +53,9 @@ export default function RootLayout() {
<QueryClientProvider client={queryClientRef.current}>
<JotaiProvider>
<JellyfinProvider>
<StatusBar style="light" backgroundColor="#000" />
<StatusBar style="auto" />
<ThemeProvider value={DarkTheme}>
<Stack screenOptions={{}}>
<Stack>
<Stack.Screen
name="(auth)/(tabs)"
options={{
@@ -77,8 +68,12 @@ export default function RootLayout() {
options={{
headerShown: true,
title: "Settings",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
presentation: "modal",
headerLeft: () => (
<TouchableOpacity onPress={() => router.back()}>
<Feather name="x-circle" size={24} color="white" />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
@@ -86,8 +81,14 @@ export default function RootLayout() {
options={{
headerShown: true,
title: "Downloads",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/player/offline/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "transparent" },
}}
/>
<Stack.Screen
@@ -102,7 +103,7 @@ export default function RootLayout() {
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: false,
}}
/>
@@ -119,7 +120,6 @@ export default function RootLayout() {
/>
<Stack.Screen name="+not-found" />
</Stack>
<CurrentlyPlayingBar />
</ThemeProvider>
</JellyfinProvider>
</JotaiProvider>

View File

@@ -1,164 +0,0 @@
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { useAtom } from "jotai";
import React, { useMemo, useState } from "react";
import { KeyboardAvoidingView, Platform, View } from "react-native";
import { z } from "zod";
const CredentialsSchema = z.object({
username: z.string().min(1, "Username is required"),
});
const Login: React.FC = () => {
const { setServer, login, removeServer } = useJellyfin();
const [api] = useAtom(apiAtom);
const [serverURL, setServerURL] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
}>({
username: "",
password: "",
});
const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => {
setLoading(true);
try {
const result = CredentialsSchema.safeParse(credentials);
if (result.success) {
await login(credentials.username, credentials.password);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const parsedServerURL = useMemo(() => {
let parsedServerURL = serverURL.trim();
if (parsedServerURL) {
parsedServerURL = parsedServerURL.endsWith("/")
? parsedServerURL.replace("/", "")
: parsedServerURL;
parsedServerURL = parsedServerURL.startsWith("http")
? parsedServerURL
: "http://" + parsedServerURL;
return parsedServerURL;
}
return "";
}, [serverURL]);
const handleConnect = (url: string) => {
setServer({ address: url });
};
if (api?.basePath) {
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<View className="flex flex-col px-4 justify-center h-full gap-y-2">
<View>
<Text className="text-3xl font-bold">Jellyfin</Text>
<Text className="opacity-50 mb-2">Server: {api.basePath}</Text>
<Button
color="black"
onPress={() => {
removeServer();
setServerURL("");
}}
justify="between"
iconLeft={
<Ionicons name="arrow-back-outline" size={18} color={"white"} />
}
>
Change server
</Button>
</View>
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold">Log in</Text>
<Input
placeholder="Username"
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="username"
clearButtonMode="while-editing"
maxLength={500}
/>
<Input
className="mb-2"
placeholder="Password"
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
value={credentials.password}
secureTextEntry
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="password"
clearButtonMode="while-editing"
maxLength={500}
/>
</View>
<Button onPress={handleLogin} loading={loading}>
Log in
</Button>
</View>
</KeyboardAvoidingView>
);
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<View className="flex flex-col px-4 justify-center h-full">
<View className="flex flex-col gap-y-2">
<Text className="text-3xl font-bold">Jellyfin</Text>
<Text className="opacity-50">Enter a server adress</Text>
<Input
className="mb-2"
placeholder="http(s)://..."
onChangeText={setServerURL}
value={serverURL}
autoFocus
secureTextEntry={false}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
maxLength={500}
/>
<Button onPress={() => handleConnect(parsedServerURL)}>
Connect
</Button>
</View>
</View>
</KeyboardAvoidingView>
);
};
export default Login;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,75 +0,0 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
export type Bitrate = {
key: string;
value: number | undefined;
};
const BITRATES: Bitrate[] = [
{
key: "Max",
value: undefined,
},
{
key: "4 Mb/s",
value: 4000000,
},
{
key: "2 Mb/s",
value: 2000000,
},
{
key: "500 Kb/s",
value: 500000,
},
];
type Props = {
onChange: (value: Bitrate) => void;
selected: Bitrate;
};
export const BitrateSelector: React.FC<Props> = ({ onChange, selected }) => {
return (
<View className="flex flex-row items-center justify-between">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{BITRATES.find((b) => b.value === selected.value)?.key}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
{BITRATES?.map((b, index: number) => (
<DropdownMenu.Item
key={index.toString()}
onSelect={() => {
onChange(b);
}}
>
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -1,12 +1,12 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useEffect } from "react";
import {
CastButton,
useCastDevice,
useDevices,
useRemoteMediaClient,
} from "react-native-google-cast";
import GoogleCast from "react-native-google-cast";
// import React, { useEffect } from "react";
// import {
// CastButton,
// useCastDevice,
// useDevices,
// useRemoteMediaClient,
// } from "react-native-google-cast";
// import GoogleCast from "react-native-google-cast";
type Props = {
item?: BaseItemDto | null;
@@ -14,21 +14,22 @@ type Props = {
};
export const Chromecast: React.FC<Props> = () => {
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
// const client = useRemoteMediaClient();
// const castDevice = useCastDevice();
// const devices = useDevices();
// const sessionManager = GoogleCast.getSessionManager();
// const discoveryManager = GoogleCast.getDiscoveryManager();
useEffect(() => {
(async () => {
if (!discoveryManager) {
return;
}
// useEffect(() => {
// (async () => {
// if (!discoveryManager) {
// return;
// }
await discoveryManager.startDiscovery();
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
// await discoveryManager.startDiscovery();
// })();
// }, [client, devices, castDevice, sessionManager, discoveryManager]);
return <CastButton style={{ tintColor: "white", height: 48, width: 48 }} />;
// return <CastButton style={{ tintColor: "white", height: 48, width: 48 }} />;
return <></>;
};

View File

@@ -1,288 +0,0 @@
import {
ActivityIndicator,
Platform,
TouchableOpacity,
View,
} from "react-native";
import { Text } from "./common/Text";
import { Ionicons } from "@expo/vector-icons";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Video, { OnProgressData, VideoRef } from "react-native-video";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios12 from "@/utils/profiles/ios12";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useRouter, useSegments } from "expo-router";
import { BlurView } from "expo-blur";
export const currentlyPlayingItemAtom = atom<{
item: BaseItemDto;
playbackUrl: string;
} | null>(null);
export const CurrentlyPlayingBar: React.FC = () => {
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
const castDevice = useCastDevice();
const client = useRemoteMediaClient();
const queryClient = useQueryClient();
const segments = useSegments();
const videoRef = useRef<VideoRef | null>(null);
const [paused, setPaused] = useState(true);
const [progress, setProgress] = useState(0);
const aBottom = useSharedValue(0);
const aPadding = useSharedValue(0);
const aHeight = useSharedValue(100);
const router = useRouter();
const animatedOuterStyle = useAnimatedStyle(() => {
return {
bottom: withTiming(aBottom.value, { duration: 500 }),
height: withTiming(aHeight.value, { duration: 500 }),
padding: withTiming(aPadding.value, { duration: 500 }),
};
});
const aPaddingBottom = useSharedValue(30);
const aPaddingInner = useSharedValue(12);
const aBorderRadiusBottom = useSharedValue(12);
const animatedInnerStyle = useAnimatedStyle(() => {
return {
padding: withTiming(aPaddingInner.value, { duration: 500 }),
paddingBottom: withTiming(aPaddingBottom.value, { duration: 500 }),
borderBottomLeftRadius: withTiming(aBorderRadiusBottom.value, {
duration: 500,
}),
borderBottomRightRadius: withTiming(aBorderRadiusBottom.value, {
duration: 500,
}),
};
});
useEffect(() => {
if (segments.find((s) => s.includes("tabs"))) {
// Tab screen - i.e. home
aBottom.value = Platform.OS === "ios" ? 78 : 50;
aHeight.value = 80;
aPadding.value = 8;
aPaddingBottom.value = 8;
aPaddingInner.value = 8;
} else {
// Inside a normal screen
aBottom.value = Platform.OS === "ios" ? 0 : 0;
aHeight.value = Platform.OS === "ios" ? 110 : 80;
aPadding.value = Platform.OS === "ios" ? 0 : 8;
aPaddingInner.value = Platform.OS === "ios" ? 12 : 8;
aPaddingBottom.value = Platform.OS === "ios" ? 40 : 12;
}
}, [segments]);
const { data: item } = useQuery({
queryKey: ["item", cp?.item.Id],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: cp?.item.Id,
}),
enabled: !!cp?.item.Id && !!api,
staleTime: 60,
});
const { data: sessionData } = useQuery({
queryKey: ["sessionData", cp?.item.Id],
queryFn: async () => {
if (!cp?.item.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: cp?.item.Id,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!cp?.item.Id && !!api && !!user?.Id,
staleTime: 0,
});
const onProgress = useCallback(
({ currentTime }: OnProgressData) => {
if (!currentTime || !sessionData?.PlaySessionId || paused) return;
const newProgress = currentTime * 10000000;
setProgress(newProgress);
reportPlaybackProgress({
api,
itemId: cp?.item.Id,
positionTicks: newProgress,
sessionId: sessionData.PlaySessionId,
});
},
[sessionData?.PlaySessionId, item, api, paused],
);
const play = () => {
if (videoRef.current) {
videoRef.current.resume();
setPaused(false);
}
};
const pause = useCallback(() => {
videoRef.current?.pause();
setPaused(true);
if (progress > 0)
reportPlaybackStopped({
api,
itemId: item?.Id,
positionTicks: progress,
sessionId: sessionData?.PlaySessionId,
});
queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
refetchType: "all",
});
}, [api, item, progress, sessionData, queryClient]);
const startPosition = useMemo(
() =>
item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0,
[item],
);
useEffect(() => {
if (cp?.playbackUrl) {
play();
}
}, [cp?.playbackUrl]);
if (!cp) return null;
return (
<Animated.View
style={[animatedOuterStyle]}
className="absolute left-0 w-screen"
>
<BlurView
intensity={Platform.OS === "android" ? 60 : 100}
experimentalBlurMethod={Platform.OS === "android" ? "none" : undefined}
className={`h-full w-full rounded-xl overflow-hidden ${Platform.OS === "android" && "bg-black"}`}
>
<Animated.View
style={[
{ padding: 8, borderTopLeftRadius: 12, borderTopEndRadius: 12 },
animatedInnerStyle,
]}
className="h-full w-full flex flex-row items-center justify-between overflow-hidden"
>
<View className="flex flex-row items-center space-x-4 shrink">
<TouchableOpacity
onPress={() => {
videoRef.current?.presentFullscreenPlayer();
}}
className="aspect-video h-full bg-neutral-800 rounded-md overflow-hidden"
>
{cp.playbackUrl && (
<Video
style={{ width: "100%", height: "100%" }}
source={{
uri: cp.playbackUrl,
isNetwork: true,
startPosition,
}}
controls={false}
ref={videoRef}
onBuffer={(e) =>
e.isBuffering ? console.log("Buffering...") : null
}
onProgress={(e) => onProgress(e)}
paused={paused}
onFullscreenPlayerDidDismiss={() => {
play();
}}
ignoreSilentSwitch="ignore"
renderLoader={
<View className="flex flex-col items-center justify-center h-full">
<ActivityIndicator size={"small"} color={"white"} />
</View>
}
/>
)}
</TouchableOpacity>
<View className="shrink text-xs">
<TouchableOpacity
onPress={() => {
router.push(`/(auth)/items/${item?.Id}/page`);
}}
>
<Text>{item?.Name}</Text>
</TouchableOpacity>
{item?.SeriesName ? (
<TouchableOpacity
onPress={() => {
router.push(`/(auth)/series/${item.SeriesId}/page`);
}}
className="text-xs opacity-50"
>
<Text>{item.SeriesName}</Text>
</TouchableOpacity>
) : (
<View>
<Text className="text-xs opacity-50">
{item?.ProductionYear}
</Text>
</View>
)}
</View>
</View>
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
if (paused) play();
else pause();
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>
{paused ? (
<Ionicons name="play" size={24} color="white" />
) : (
<Ionicons name="pause" size={24} color="white" />
)}
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setCp(null);
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
</Animated.View>
</BlurView>
</Animated.View>
);
};

View File

@@ -11,17 +11,16 @@ import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import ProgressCircle from "./ProgressCircle";
import { Text } from "./common/Text";
import { useDownloadMedia } from "@/hooks/useDownloadMedia";
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
type DownloadProps = {
item: BaseItemDto;
playbackUrl: string;
playbackURL: string;
};
export const DownloadItem: React.FC<DownloadProps> = ({
item,
playbackUrl,
playbackURL,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -30,8 +29,6 @@ export const DownloadItem: React.FC<DownloadProps> = ({
const { downloadMedia, isDownloading, error, cancelDownload } =
useDownloadMedia(api, user?.Id);
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
const { data: playbackInfo, isLoading } = useQuery({
queryKey: ["playbackInfo", item.Id],
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
@@ -88,7 +85,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
{process ? (
<TouchableOpacity
onPress={() => {
cancelRemuxing();
// cancelRemuxing();
}}
className="flex flex-row items-center"
>
@@ -133,7 +130,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
<TouchableOpacity
onPress={() => {
// downloadFile();
startRemuxing();
// startRemuxing();
}}
>
<Ionicons name="cloud-download-outline" size={26} color="white" />

View File

@@ -29,9 +29,15 @@ export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
uri: url,
isNetwork: false,
}}
controls
ref={videoRef}
onError={onError}
ignoreSilentSwitch="ignore"
resizeMode="contain"
reportBandwidth
style={{
width: "100%",
aspectRatio: 16 / 9,
}}
/>
);
};

View File

@@ -32,14 +32,14 @@ export const ParallaxScrollView: React.FC<Props> = ({
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[2, 1, 1],
[2, 1, 1]
),
},
],
@@ -61,7 +61,7 @@ export const ParallaxScrollView: React.FC<Props> = ({
onPress={() => router.back()}
className="absolute left-4 z-50 bg-black rounded-full p-2 border border-neutral-900"
style={{
top: inset.top + 17,
top: inset.top,
}}
>
<Ionicons

View File

@@ -1,34 +0,0 @@
import { useState } from "react";
import { Button } from "./Button";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { currentlyPlayingItemAtom } from "./CurrentlyPlayingBar";
import { useAtom } from "jotai";
import { Feather, Ionicons } from "@expo/vector-icons";
import { runtimeTicksToMinutes } from "@/utils/time";
type Props = {
item: BaseItemDto;
onPress: () => void;
chromecastReady: boolean;
};
export const PlayButton: React.FC<Props> = ({
item,
onPress,
chromecastReady,
}) => {
return (
<Button
onPress={onPress}
iconRight={
chromecastReady ? (
<Feather name="cast" size={20} color="white" />
) : (
<Ionicons name="play-circle" size={24} color="white" />
)
}
>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Button>
);
};

View File

@@ -23,13 +23,31 @@ import { chromecastProfile } from "@/utils/profiles/chromecast";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { currentlyPlayingItemAtom } from "./CurrentlyPlayingBar";
type VideoPlayerProps = {
itemId: string;
onChangePlaybackURL: (url: string | null) => void;
};
const BITRATES = [
{
key: "Max",
value: undefined,
},
{
key: "4 Mb/s",
value: 4000000,
},
{
key: "2 Mb/s",
value: 2000000,
},
{
key: "500 Kb/s",
value: 500000,
},
];
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
itemId,
onChangePlaybackURL,
@@ -176,8 +194,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
});
}, [item, client, playbackURL]);
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
useEffect(() => {
videoRef.current?.pause();
}, []);
@@ -247,15 +263,14 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
<Button
disabled={!enableVideo}
onPress={() => {
// if (chromecastReady) {
// cast();
// } else {
// setTimeout(() => {
// if (!videoRef.current) return;
// videoRef.current.presentFullscreenPlayer();
// }, 1000);
// }
if (item) setCp(item);
if (chromecastReady) {
cast();
} else {
setTimeout(() => {
if (!videoRef.current) return;
videoRef.current.presentFullscreenPlayer();
}, 1000);
}
}}
iconRight={
chromecastReady ? (

View File

@@ -1,22 +1,15 @@
import React, { useEffect } from "react";
import React from "react";
import { TextInputProps, TextProps } from "react-native";
import { TextInput } from "react-native";
export function Input(props: TextInputProps) {
const { style, ...otherProps } = props;
const inputRef = React.useRef<TextInput>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return (
<TextInput
ref={inputRef}
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
placeholderTextColor={"#9CA3AF"}
/>
);
}

View File

@@ -1,23 +1,25 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { TouchableOpacity } from "react-native";
import * as ContextMenu from "zeego/context-menu";
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import * as Haptics from "expo-haptics";
import { useCallback } from "react";
import { useRef, useMemo, useState } from "react";
import Video, { VideoRef } from "react-native-video";
import * as FileSystem from "expo-file-system";
import { useAtom } from "jotai";
import { currentlyPlayingItemAtom } from "../CurrentlyPlayingBar";
export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const { deleteFile } = useFiles();
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
const videoRef = useRef<VideoRef | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const openFile = useCallback(() => {
setCp({
item,
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
const openFile = () => {
videoRef.current?.presentFullscreenPlayer();
};
const fileUrl = useMemo(() => {
return `${FileSystem.documentDirectory}/${item.Id}.mp4`;
}, [item]);
const options = [
@@ -70,6 +72,26 @@ export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
))}
</ContextMenu.Content>
</ContextMenu.Root>
<Video
style={{ width: 0, height: 0 }}
source={{
uri: fileUrl,
isNetwork: false,
}}
controls
onFullscreenPlayerDidDismiss={() => {
setIsPlaying(false);
videoRef.current?.pause();
}}
onFullscreenPlayerDidPresent={() => {
setIsPlaying(true);
videoRef.current?.resume();
}}
ref={videoRef}
resizeMode="contain"
paused={!isPlaying}
/>
</>
);
};

View File

@@ -3,22 +3,30 @@ import { Text } from "../common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { runtimeTicksToMinutes } from "@/utils/time";
import * as ContextMenu from "zeego/context-menu";
import { router } from "expo-router";
import { useFiles } from "@/hooks/useFiles";
import Video, {
OnBufferData,
OnPlaybackStateChangedData,
OnProgressData,
OnVideoErrorData,
VideoRef,
} from "react-native-video";
import * as FileSystem from "expo-file-system";
import { useCallback } from "react";
import { useMemo, useRef, useState } from "react";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import { currentlyPlayingItemAtom } from "../CurrentlyPlayingBar";
export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const { deleteFile } = useFiles();
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
const videoRef = useRef<VideoRef | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const openFile = useCallback(() => {
setCp({
item,
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
const openFile = () => {
videoRef.current?.presentFullscreenPlayer();
};
const fileUrl = useMemo(() => {
return `${FileSystem.documentDirectory}/${item.Id}.mp4`;
}, [item]);
const options = [
@@ -74,6 +82,26 @@ export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
))}
</ContextMenu.Content>
</ContextMenu.Root>
<Video
style={{ width: 0, height: 0 }}
source={{
uri: fileUrl,
isNetwork: false,
}}
controls
onFullscreenPlayerDidDismiss={() => {
setIsPlaying(false);
videoRef.current?.pause();
}}
onFullscreenPlayerDidPresent={() => {
setIsPlaying(true);
videoRef.current?.resume();
}}
ref={videoRef}
resizeMode="contain"
paused={!isPlaying}
/>
</>
);
};

View File

@@ -1,28 +1,26 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai";
import { useMemo } from "react";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
type Props = {
item: BaseItemDto;
};
export const seasonIndexAtom = atom<number>(1);
export const SeasonPicker: React.FC<Props> = ({ item }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [seasonIndex, setSeasonIndex] = useAtom(seasonIndexAtom);
const router = useRouter();
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
const [selectedSeasonId, setSelectedSeasonId] = useState<string | null>(null);
const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id],
@@ -40,7 +38,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
},
}
);
return response.data.Items;
@@ -48,12 +46,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
enabled: !!api && !!user?.Id && !!item.Id,
});
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
[seasons, seasonIndex],
);
const { data: episodes } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
@@ -70,7 +62,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
},
}
);
return response.data.Items as BaseItemDto[];
@@ -78,13 +70,22 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
useEffect(() => {
if (!seasons || seasons.length === 0) return;
setSelectedSeasonId(
seasons.find((season: any) => season.IndexNumber === 1)?.Id
);
setSelectedSeason(1);
}, [seasons]);
return (
<View className="mb-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-row px-4">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>Season {seasonIndex}</Text>
<Text>Season {selectedSeason}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
@@ -102,7 +103,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
<DropdownMenu.Item
key={season.Name}
onSelect={() => {
setSeasonIndex(season.IndexNumber);
setSelectedSeason(season.IndexNumber);
setSelectedSeasonId(season.Id);
}}
>
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>

View File

@@ -20,12 +20,7 @@
"simulator": true
}
},
"production": {
"channel": "0.0.6",
"android": {
"image": "latest"
}
}
"production": {}
},
"submit": {
"production": {}

View File

@@ -1,127 +0,0 @@
import { useCallback } from "react";
import { useAtom } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { runningProcesses } from "@/utils/atoms/downloads";
import { writeToLog } from "@/utils/log";
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
*
* @param url - The URL of the HLS stream
* @param item - The BaseItemDto object representing the media item
* @returns An object with remuxing-related functions
*/
export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
const [_, setProgress] = useAtom(runningProcesses);
if (!item.Id || !item.Name) {
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
throw new Error("Item must have an Id and Name");
}
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
const command = `-y -fflags +genpts -i ${url} -c copy -bufsize 10M -max_muxing_queue_size 4096 ${output}`;
const startRemuxing = useCallback(async () => {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`,
);
try {
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
const videoLength =
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
const totalFrames = videoLength * fps;
const processedFrames = statistics.getVideoFrameNumber();
const speed = statistics.getSpeed();
const percentage =
totalFrames > 0
? Math.floor((processedFrames / totalFrames) * 100)
: 0;
setProgress((prev) =>
prev?.item.Id === item.Id!
? { ...prev, progress: percentage, speed }
: prev,
);
});
await FFmpegKit.executeAsync(command, async (session) => {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
await updateDownloadedFiles(item);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
);
} else if (returnCode.isValueError()) {
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
} else if (returnCode.isValueCancel()) {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
);
}
setProgress(null);
});
} catch (error) {
console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
setProgress(null);
}
}, [output, item, command, setProgress]);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
setProgress(null);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`,
);
}, [item.Name, setProgress]);
return { startRemuxing, cancelRemuxing };
};
/**
* Updates the list of downloaded files in AsyncStorage.
*
* @param item - The item to add to the downloaded files list
*/
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
try {
const currentFiles: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]",
);
const updatedFiles = [
...currentFiles.filter((i) => i.Id !== item.Id),
item,
];
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify(updatedFiles),
);
} catch (error) {
console.error("Error updating downloaded files:", error);
writeToLog(
"ERROR",
`Failed to update downloaded files for item: ${item.Name}`,
);
}
}

View File

@@ -15,7 +15,6 @@
"preset": "jest-expo"
},
"dependencies": {
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/vector-icons": "^14.0.2",
"@jellyfin/sdk": "^0.10.0",
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
@@ -26,7 +25,6 @@
"@tanstack/react-query": "^5.51.16",
"@types/uuid": "^10.0.0",
"expo": "~51.0.26",
"expo-blur": "~13.0.2",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.22",
@@ -36,14 +34,11 @@
"expo-image": "~1.12.13",
"expo-keep-awake": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-navigation-bar": "~3.0.7",
"expo-router": "~3.5.21",
"expo-splash-screen": "~0.27.5",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.22",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"jotai": "^2.9.1",
"nativewind": "^2.0.11",
"react": "18.2.0",
@@ -53,7 +48,6 @@
"react-native-compressor": "^1.8.25",
"react-native-gesture-handler": "~2.16.1",
"react-native-get-random-values": "^1.11.0",
"react-native-google-cast": "^4.8.2",
"react-native-ios-context-menu": "^2.5.1",
"react-native-ios-utilities": "^4.4.5",
"react-native-reanimated": "~3.10.1",