diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e9ae48e7..8c9ad540 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -22,11 +22,6 @@ - - - - - diff --git a/android/app/src/main/res/drawable-hdpi/splashscreen_image.png b/android/app/src/main/res/drawable-hdpi/splashscreen_image.png index 51785bdc..7ea0d77f 100644 Binary files a/android/app/src/main/res/drawable-hdpi/splashscreen_image.png and b/android/app/src/main/res/drawable-hdpi/splashscreen_image.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splashscreen_image.png b/android/app/src/main/res/drawable-mdpi/splashscreen_image.png index 51785bdc..7ea0d77f 100644 Binary files a/android/app/src/main/res/drawable-mdpi/splashscreen_image.png and b/android/app/src/main/res/drawable-mdpi/splashscreen_image.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splashscreen_image.png b/android/app/src/main/res/drawable-xhdpi/splashscreen_image.png index 51785bdc..7ea0d77f 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/splashscreen_image.png and b/android/app/src/main/res/drawable-xhdpi/splashscreen_image.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splashscreen_image.png b/android/app/src/main/res/drawable-xxhdpi/splashscreen_image.png index 51785bdc..7ea0d77f 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/splashscreen_image.png and b/android/app/src/main/res/drawable-xxhdpi/splashscreen_image.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_image.png b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_image.png index 51785bdc..7ea0d77f 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_image.png and b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_image.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 6aad50d8..5c2a7520 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 6aad50d8..5c2a7520 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index df5e1733..277338c9 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 1317c233..b2a7de79 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 1317c233..b2a7de79 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index f821ab19..5a7cf5a9 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 19a5881f..4a2f9c28 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index 19a5881f..4a2f9c28 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 5c7e700b..bde5a678 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index e4b5fc89..b4fa982a 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index e4b5fc89..b4fa982a 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index df159d9c..0ad5b061 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 69e01bbc..37e5c48e 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 69e01bbc..37e5c48e 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 336d67bb..db4280de 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app.json b/app.json index e4ce296b..d99db8a3 100644 --- a/app.json +++ b/app.json @@ -4,11 +4,11 @@ "slug": "streamyfin", "version": "0.0.4", "orientation": "portrait", - "icon": "./assets/images/icon.jpg", + "icon": "./assets/images/icon.png", "scheme": "streamyfin", "userInterfaceStyle": "dark", "splash": { - "image": "./assets/images/splash.jpg", + "image": "./assets/images/splash.png", "resizeMode": "contain", "backgroundColor": "#ffffff" }, @@ -16,7 +16,8 @@ "ios": { "userInterfaceStyle": "dark", "infoPlist": { - "NSCameraUsageDescription": "The app needs access to your camera to scan barcodes." + "NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.", + "NSMicrophoneUsageDescription": "The app needs access to your microphone." }, "supportsTablet": true, "bundleIdentifier": "com.fredrikburmester.streamyfin" @@ -24,22 +25,16 @@ "android": { "userInterfaceStyle": "light", "adaptiveIcon": { - "foregroundImage": "./assets/images/icon.jpg", + "foregroundImage": "./assets/images/icon.png", "backgroundColor": "#ffffff" }, "package": "com.fredrikburmester.streamyfin" }, - "web": { - "bundler": "metro", - "output": "static", - "favicon": "./assets/images/favicon.png" - }, "plugins": [ "expo-router", "expo-font", "expo-video", "react-native-compressor", - // "react-native-google-cast", [ "react-native-video", { diff --git a/app/(auth)/(tabs)/index.tsx b/app/(auth)/(tabs)/index.tsx index b19d9995..994b783f 100644 --- a/app/(auth)/(tabs)/index.tsx +++ b/app/(auth)/(tabs)/index.tsx @@ -5,11 +5,13 @@ import { ItemCardText } from "@/components/ItemCardText"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi, getSuggestionsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import { useAtom } from "jotai"; +import { useCallback, useState } from "react"; import { ActivityIndicator, + RefreshControl, ScrollView, TouchableOpacity, View, @@ -21,7 +23,7 @@ export default function index() { const router = useRouter(); const { data, isLoading, isError } = useQuery({ - queryKey: ["resumeItems", api, user?.Id], + queryKey: ["resumeItems", user?.Id], queryFn: async () => { if (!api || !user?.Id) { return []; @@ -85,6 +87,18 @@ export default function index() { staleTime: 60, }); + const queryClient = useQueryClient(); + + const [loading, setLoading] = useState(false); + + const refetch = useCallback(async () => { + setLoading(true); + await queryClient.refetchQueries({ queryKey: ["resumeItems", user?.Id] }); + await queryClient.refetchQueries({ queryKey: ["items", user?.Id] }); + await queryClient.refetchQueries({ queryKey: ["suggestions", user?.Id] }); + setLoading(false); + }, [queryClient, user?.Id]); + if (isError) return ( @@ -105,7 +119,12 @@ export default function index() { if (!data || data.length === 0) return No data...; return ( - + + } + > Continue Watching diff --git a/app/(auth)/(tabs)/search.tsx b/app/(auth)/(tabs)/search.tsx index 62410f29..d750ffb5 100644 --- a/app/(auth)/(tabs)/search.tsx +++ b/app/(auth)/(tabs)/search.tsx @@ -67,8 +67,8 @@ export default function search() { return ( - - + + - Movies + Movies m.Id!)} renderItem={(data) => ( @@ -101,7 +101,7 @@ export default function search() { /> )} /> - Series + Series m.Id!)} renderItem={(data) => ( @@ -123,7 +123,7 @@ export default function search() { /> )} /> - Episodes + Episodes m.Id!)} renderItem={(data) => ( @@ -195,7 +195,7 @@ const SearchItemWrapper: React.FC = ({ ids, renderItem }) => { staleTime: Infinity, }); - if (!data) return No results; + if (!data) return No results; return renderItem(data); }; diff --git a/app/(auth)/items/[id]/ParallaxPage.tsx b/app/(auth)/items/[id]/ParallaxPage.tsx new file mode 100644 index 00000000..a238e14f --- /dev/null +++ b/app/(auth)/items/[id]/ParallaxPage.tsx @@ -0,0 +1,83 @@ +import type { PropsWithChildren, ReactElement } from "react"; +import { + NativeScrollEvent, + NativeSyntheticEvent, + StyleSheet, + useColorScheme, +} from "react-native"; +import Animated, { + interpolate, + useAnimatedRef, + useAnimatedStyle, + useScrollViewOffset, +} from "react-native-reanimated"; + +import { ThemedView } from "@/components/ThemedView"; + +const HEADER_HEIGHT = 250; + +type Props = PropsWithChildren<{ + headerImage: ReactElement; + onScroll?: (event: NativeSyntheticEvent) => void; +}>; + +export const ParallaxScrollView: React.FC = ({ + children, + headerImage, + onScroll, +}: Props) => { + const scrollRef = useAnimatedRef(); + const scrollOffset = useScrollViewOffset(scrollRef); + + const headerAnimatedStyle = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: interpolate( + scrollOffset.value, + [-HEADER_HEIGHT, 0, HEADER_HEIGHT], + [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] + ), + }, + { + scale: interpolate( + scrollOffset.value, + [-HEADER_HEIGHT, 0, HEADER_HEIGHT], + [2, 1, 1] + ), + }, + ], + }; + }); + + return ( + + + + {headerImage} + + {children} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + height: 250, + overflow: "hidden", + }, + content: { + flex: 1, + overflow: "hidden", + }, +}); diff --git a/app/(auth)/items/[id]/page.tsx b/app/(auth)/items/[id]/page.tsx index 91335eea..dc6c831e 100644 --- a/app/(auth)/items/[id]/page.tsx +++ b/app/(auth)/items/[id]/page.tsx @@ -13,8 +13,16 @@ import {} from "@jellyfin/sdk/lib/utils/url"; import { useQuery } from "@tanstack/react-query"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; -import { useEffect } from "react"; -import { ActivityIndicator, ScrollView, View } from "react-native"; +import { useEffect, useState } from "react"; +import { + ActivityIndicator, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, + View, +} from "react-native"; +import { ParallaxScrollView } from "./ParallaxPage"; +import { Image } from "expo-image"; const page: React.FC = () => { const local = useLocalSearchParams(); @@ -23,8 +31,6 @@ const page: React.FC = () => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const navigation = useNavigation(); - const { data: item, isLoading: l1 } = useQuery({ queryKey: ["item", id], queryFn: async () => @@ -34,22 +40,14 @@ const page: React.FC = () => { itemId: id, }), enabled: !!id && !!api, - staleTime: Infinity, + staleTime: 60, }); - useEffect(() => { - navigation.setOptions({ - headerRight: () => { - ; - }, - }); - }, [item, navigation]); - const { data: posterUrl } = useQuery({ queryKey: ["backdrop", item?.Id], queryFn: async () => getBackdrop(api, item), enabled: !!api && !!item?.Id, - staleTime: Infinity, + staleTime: 60 * 60 * 24 * 7, }); if (l1) @@ -59,12 +57,23 @@ const page: React.FC = () => { ); - if (!item?.Id) return null; + if (!item?.Id || !posterUrl) return null; return ( - - - + + } + > + {item.Type === "Episode" ? ( <> @@ -127,12 +136,10 @@ const page: React.FC = () => { - - - + {item.Type === "Episode" && ( - + )} @@ -140,7 +147,7 @@ const page: React.FC = () => { - + ); }; diff --git a/app/(auth)/series/[id]/page.tsx b/app/(auth)/series/[id]/page.tsx index 86e768f1..f9862530 100644 --- a/app/(auth)/series/[id]/page.tsx +++ b/app/(auth)/series/[id]/page.tsx @@ -18,7 +18,7 @@ const page: React.FC = () => { const [user] = useAtom(userAtom); const { data: item } = useQuery({ - queryKey: ["item", seriesId], + queryKey: ["series", seriesId], queryFn: async () => await getUserItemData({ api, @@ -26,33 +26,23 @@ const page: React.FC = () => { itemId: seriesId, }), enabled: !!seriesId && !!api, - staleTime: Infinity, - }); - - const { data: next } = useQuery({ - queryKey: ["nextUp", seriesId], - queryFn: async () => - await nextUp({ - userId: user?.Id, - api, - itemId: seriesId, - }), - enabled: !!api && !!seriesId && !!user?.Id, - staleTime: 0, + staleTime: 60, }); if (!item) return null; return ( - - - - {item?.Name} - {item?.Overview} + + + + + {item?.Name} + {item?.Overview} + - + ); diff --git a/app/(auth)/settings.tsx b/app/(auth)/settings.tsx index 6db4dc87..3d28f5d1 100644 --- a/app/(auth)/settings.tsx +++ b/app/(auth)/settings.tsx @@ -34,15 +34,22 @@ const deleteAllFiles = async () => { const deleteFile = async (id: string | null | undefined) => { if (!id) return; - FileSystem.deleteAsync(`${FileSystem.documentDirectory}/${id}.mp4`).catch( - (err) => console.error(err) - ); + try { + FileSystem.deleteAsync(`${FileSystem.documentDirectory}/${id}.mp4`).catch( + (err) => console.error(err) + ); - const currentFiles = JSON.parse( - (await AsyncStorage.getItem("downloaded_files")) ?? "[]" - ); - const updatedFiles = currentFiles.filter((f: string) => f !== id); - await AsyncStorage.setItem("downloaded_files", JSON.stringify(updatedFiles)); + const currentFiles = JSON.parse( + (await AsyncStorage.getItem("downloaded_files")) ?? "[]" + ) as BaseItemDto[]; + const updatedFiles = currentFiles.filter((f) => f.Id !== id); + await AsyncStorage.setItem( + "downloaded_files", + JSON.stringify(updatedFiles) + ); + } catch (error) { + console.error(error); + } }; const listDownloadedFiles = async () => { @@ -125,8 +132,8 @@ export default function settings() { subTitle={file.ProductionYear?.toString()} iconAfter={ { - deleteFile(file.Id); + onPress={async () => { + await deleteFile(file.Id); setKey((prevKey) => prevKey + 1); }} > @@ -142,18 +149,20 @@ export default function settings() { ))} ) : activeProcess ? ( - - } - /> + + + } + /> + ) : ( No downloaded files )} diff --git a/app/_layout.tsx b/app/_layout.tsx index c81384f9..e0564d46 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -30,12 +30,10 @@ export default function RootLayout() { defaultOptions: { queries: { staleTime: 60, - refetchOnMount: false, - refetchInterval: false, - refetchIntervalInBackground: false, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - retryOnMount: false, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + retryOnMount: true, }, }, }) diff --git a/assets/images/icon.png b/assets/images/icon.png new file mode 100644 index 00000000..01b93990 Binary files /dev/null and b/assets/images/icon.png differ diff --git a/assets/images/splash.png b/assets/images/splash.png new file mode 100644 index 00000000..ebb588a2 Binary files /dev/null and b/assets/images/splash.png differ diff --git a/bun.lockb b/bun.lockb index cd531206..7c43504a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx index 4fff599f..b7daa5e6 100644 --- a/components/ContinueWatchingPoster.tsx +++ b/components/ContinueWatchingPoster.tsx @@ -23,7 +23,7 @@ const ContinueWatchingPoster: React.FC = ({ queryKey: ["backdrop", item.Id], queryFn: async () => getBackdrop(api, item), enabled: !!api && !!item.Id, - staleTime: Infinity, + staleTime: 60 * 60 * 24 * 7, }); const [progress, setProgress] = useState( diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 1de0d601..9640b2d4 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -1,182 +1,50 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { writeToLog } from "@/utils/log"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { runningProcesses } from "@/utils/atoms/downloads"; +import { getPlaybackInfo, useDownloadMedia } from "@/utils/jellyfin"; import Ionicons from "@expo/vector-icons/Ionicons"; 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 { atom, useAtom } from "jotai"; -import { useCallback, useEffect, useState } from "react"; -import { TouchableOpacity, View } from "react-native"; -import ProgressCircle from "./ProgressCircle"; import { router } from "expo-router"; -import { getPlaybackInfo, useDownloadMedia } from "@/utils/jellyfin"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { ProcessItem, runningProcesses } from "@/utils/atoms/downloads"; +import { useAtom } from "jotai"; +import { useCallback, useEffect, useState } from "react"; +import { ActivityIndicator, TouchableOpacity, View } from "react-native"; +import ProgressCircle from "./ProgressCircle"; +import { Text } from "./common/Text"; +import { useQuery } from "@tanstack/react-query"; type DownloadProps = { item: BaseItemDto; }; -// const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => { -// if (!item.Id || !item.Name) { -// writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments", { -// item, -// inputUrl, -// }); -// throw new Error("Item must have an Id and Name"); -// } - -// const [session, setSession] = useAtom(runningProcesses); - -// const output = `${FileSystem.documentDirectory}${item.Id}.mp4`; - -// const command = `-y -fflags +genpts -i ${inputUrl} -c copy -max_muxing_queue_size 9999 ${output}`; - -// const startRemuxing = useCallback(async () => { -// if (!item.Id || !item.Name) { -// writeToLog( -// "ERROR", -// "useRemuxHlsToMp4 ~ startRemuxing ~ missing arguments", -// { -// item, -// inputUrl, -// } -// ); -// throw new Error("Item must have an Id and Name"); -// } - -// writeToLog( -// "INFO", -// `useRemuxHlsToMp4 ~ startRemuxing for item ${item.Id} with url ${inputUrl}`, -// { -// item, -// inputUrl, -// } -// ); - -// try { -// setSession({ -// item, -// progress: 0, -// }); - -// FFmpegKitConfig.enableStatisticsCallback((statistics) => { -// let percentage = 0; - -// 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(); - -// if (totalFrames > 0) { -// percentage = Math.floor((processedFrames / totalFrames) * 100); -// } - -// setSession((prev) => { -// return prev?.item.Id === item.Id! -// ? { ...prev, progress: percentage } -// : prev; -// }); -// }); - -// await FFmpegKit.executeAsync(command, async (session) => { -// const returnCode = await session.getReturnCode(); -// if (returnCode.isValueSuccess()) { -// const currentFiles: BaseItemDto[] = JSON.parse( -// (await AsyncStorage.getItem("downloaded_files")) || "[]" -// ); - -// const otherItems = currentFiles.filter((i) => i.Id !== item.Id); - -// await AsyncStorage.setItem( -// "downloaded_files", -// JSON.stringify([...otherItems, item]) -// ); - -// writeToLog( -// "INFO", -// `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`, -// { -// item, -// inputUrl, -// } -// ); -// setSession(null); -// } else if (returnCode.isValueError()) { -// console.error("Failed to remux:"); -// writeToLog( -// "ERROR", -// `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`, -// { -// item, -// inputUrl, -// } -// ); -// setSession(null); -// } else if (returnCode.isValueCancel()) { -// console.log("Remuxing was cancelled"); -// writeToLog( -// "INFO", -// `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`, -// { -// item, -// inputUrl, -// } -// ); -// setSession(null); -// } -// }); -// } catch (error) { -// console.error("Failed to remux:", error); -// writeToLog( -// "ERROR", -// `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`, -// { -// item, -// inputUrl, -// } -// ); -// } -// }, [inputUrl, output, item, command]); - -// const cancelRemuxing = useCallback(async () => { -// FFmpegKit.cancel(); -// setSession(null); -// console.log("Remuxing cancelled"); -// }, []); - -// return { session, startRemuxing, cancelRemuxing }; -// }; - export const DownloadItem: React.FC = ({ item }) => { - // const { session, startRemuxing, cancelRemuxing } = useRemuxHlsToMp4( - // url, - // item - // ); - const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [process] = useAtom(runningProcesses); - const { downloadMedia, isDownloading, error } = useDownloadMedia(api); + const { downloadMedia, isDownloading, error, cancelDownload } = + useDownloadMedia(api, user?.Id); + + const { data: playbackInfo, isLoading } = useQuery({ + queryKey: ["playbackInfo", item.Id], + queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id), + }); const downloadFile = useCallback(async () => { - const playbackInfo = await getPlaybackInfo(api, item.Id, user?.Id); + if (!playbackInfo) return; - const source = playbackInfo?.MediaSources?.[0]; + const source = playbackInfo.MediaSources?.[0]; if (source?.SupportsDirectPlay && item.CanDownload) { downloadMedia(item); } else { - console.log("file not supported"); + throw new Error( + "Direct play not supported thus the file cannot be downloaded" + ); } - }, [item, user]); + }, [item, user, playbackInfo]); const [downloaded, setDownloaded] = useState(false); - const [key, setKey] = useState(""); useEffect(() => { (async () => { @@ -186,7 +54,19 @@ export const DownloadItem: React.FC = ({ item }) => { if (data.find((d) => d.Id === item.Id)) setDownloaded(true); })(); - }, [key]); + }, [process]); + + if (isLoading) { + return ; + } + + if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) { + return ( + + + + ); + } if (process && process.item.Id !== item.Id!) { return ( @@ -201,17 +81,22 @@ export const DownloadItem: React.FC = ({ item }) => { {process ? ( { - // cancelRemuxing(); + cancelDownload(); }} - className="-rotate-45" + className="relative" > - + + + + + {process.progress.toFixed(0)}% + ) : downloaded ? ( = ({ item }) => { ); }} > - + ) : ( = ({ item }) => { downloadFile(); }} > - + )} diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx index b527f1d0..d6b73442 100644 --- a/components/PlayedStatus.tsx +++ b/components/PlayedStatus.tsx @@ -4,7 +4,7 @@ import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useQueryClient, InvalidateQueryFilters } from "@tanstack/react-query"; import { useAtom } from "jotai"; -import React from "react"; +import React, { useCallback } from "react"; import { TouchableOpacity, View } from "react-native"; import * as Haptics from "expo-haptics"; @@ -14,6 +14,29 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => { const queryClient = useQueryClient(); + const invalidateQueries = useCallback(() => { + queryClient.invalidateQueries({ + queryKey: ["item", item.Id], + refetchType: "all", + }); + queryClient.invalidateQueries({ + queryKey: ["resumeItems", user?.Id], + refetchType: "all", + }); + queryClient.invalidateQueries({ + queryKey: ["nextUp", item.SeriesId], + refetchType: "all", + }); + queryClient.invalidateQueries({ + queryKey: ["episodes"], + refetchType: "all", + }); + queryClient.invalidateQueries({ + queryKey: ["seasons"], + refetchType: "all", + }); + }, [api, item.Id, queryClient, user?.Id]); + return ( {item.UserData?.Played ? ( @@ -24,11 +47,8 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => { itemId: item?.Id, userId: user?.Id, }); - queryClient.invalidateQueries({ - queryKey: ["item", item.Id], - refetchType: "all", - }); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + invalidateQueries(); }} > @@ -41,11 +61,8 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => { itemId: item?.Id, userId: user?.Id, }); - queryClient.invalidateQueries({ - queryKey: ["item", item.Id], - refetchType: "all", - }); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + invalidateQueries(); }} > diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx index 660fe15e..67715a93 100644 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -1,14 +1,17 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { - ActivityIndicator, - Switch, - TouchableOpacity, - View, -} from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; + getStreamUrl, + getUserItemData, + reportPlaybackProgress, + reportPlaybackStopped, +} from "@/utils/jellyfin"; +import { runtimeTicksToMinutes } from "@/utils/time"; +import { Ionicons } from "@expo/vector-icons"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { useAtom } from "jotai"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import Video, { OnBufferData, OnPlaybackStateChangedData, @@ -16,17 +19,8 @@ import Video, { OnVideoErrorData, VideoRef, } from "react-native-video"; -import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; -import { - getBackdrop, - getStreamUrl, - getUserItemData, - reportPlaybackProgress, - reportPlaybackStopped, -} from "@/utils/jellyfin"; -import { Ionicons } from "@expo/vector-icons"; +import * as DropdownMenu from "zeego/dropdown-menu"; import { Button } from "./Button"; -import { runtimeTicksToMinutes } from "@/utils/time"; import { Text } from "./common/Text"; type VideoPlayerProps = { @@ -36,11 +30,7 @@ type VideoPlayerProps = { const BITRATES = [ { key: "Max", - value: 140000000, - }, - { - key: "10 Mb/s", - value: 10000000, + value: undefined, }, { key: "4 Mb/s", @@ -50,10 +40,6 @@ const BITRATES = [ key: "2 Mb/s", value: 2000000, }, - { - key: "1 Mb/s", - value: 1000000, - }, { key: "500 Kb/s", value: 500000, @@ -62,12 +48,8 @@ const BITRATES = [ export const VideoPlayer: React.FC = ({ itemId }) => { const videoRef = useRef(null); - const [showPoster, setShowPoster] = useState(true); - const [isPlaying, setIsPlaying] = useState(false); - const [buffering, setBuffering] = useState(false); - const [maxBitrate, setMaxbitrate] = useState(140000000); + const [maxBitrate, setMaxbitrate] = useState(undefined); const [paused, setPaused] = useState(true); - const [forceTranscoding, setForceTranscoding] = useState(false); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -81,7 +63,7 @@ export const VideoPlayer: React.FC = ({ itemId }) => { itemId, }), enabled: !!itemId && !!api, - staleTime: 0, + staleTime: 60, }); const { data: sessionData } = useQuery({ @@ -182,7 +164,9 @@ export const VideoPlayer: React.FC = ({ itemId }) => { return ( - {playbackURL && ( + {enableVideo === true && + playbackURL !== null && + playbackURL !== undefined ? (