diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx index af7cf7d5..30ba6352 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads.tsx @@ -7,10 +7,9 @@ import { queueAtom } from "@/utils/atoms/queue"; import { useSettings } from "@/utils/atoms/settings"; import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import * as FileSystem from "expo-file-system"; import { router } from "expo-router"; import { useAtom } from "jotai"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -45,8 +44,8 @@ const downloads: React.FC = () => { paddingBottom: 100, }} > - - + + {settings?.downloadMethod === "remux" && ( Queue @@ -88,26 +87,31 @@ const downloads: React.FC = () => { + {movies.length > 0 && ( - + Movies {movies?.length} - {movies?.map((item: BaseItemDto) => ( - - + + + {movies?.map((item: BaseItemDto) => ( + + + + ))} - ))} + )} {groupedBySeries?.map((items: BaseItemDto[], index: number) => ( ))} {downloadedFiles?.length === 0 && ( - + No downloaded items )} diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 456dae9c..e3ef4f09 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -226,12 +226,13 @@ export default function search() { contentContainerStyle={{ paddingLeft: insets.left, paddingRight: insets.right, + paddingBottom: 16, }} style={{ marginBottom: TAB_HEIGHT, }} > - + {Platform.OS === "android" && ( m.Id!)} - renderItem={(data) => ( - ( - - - - {item.Name} - - - {item.ProductionYear} - - - )} - /> + renderItem={(item) => ( + + + + {item.Name} + + + {item.ProductionYear} + + )} /> m.Id!)} header="Series" - renderItem={(data) => ( - ( - - - - {item.Name} - - - {item.ProductionYear} - - - )} - /> + renderItem={(item) => ( + + + + {item.Name} + + + {item.ProductionYear} + + )} /> m.Id!)} header="Episodes" - renderItem={(data) => ( - ( - - - - - )} - /> + renderItem={(item) => ( + + + + )} /> m.Id!)} header="Collections" - renderItem={(data) => ( - ( - - - - {item.Name} - - - )} - /> + renderItem={(item) => ( + + + + {item.Name} + + )} /> m.Id!)} header="Actors" - renderItem={(data) => ( - ( - - - - - )} - /> + renderItem={(item) => ( + + + + )} /> m.Id!)} header="Artists" - renderItem={(data) => ( - ( - - - - - )} - /> + renderItem={(item) => ( + + + + )} /> m.Id!)} header="Albums" - renderItem={(data) => ( - ( - - - - - )} - /> + renderItem={(item) => ( + + + + )} /> m.Id!)} header="Songs" - renderItem={(data) => ( - ( - - - - - )} - /> + renderItem={(item) => ( + + + + )} /> {loading ? ( @@ -449,7 +410,7 @@ export default function search() { type Props = { ids?: string[] | null; - renderItem: (data: BaseItemDto[]) => React.ReactNode; + renderItem: (item: BaseItemDto) => React.ReactNode; header?: string; }; @@ -487,8 +448,14 @@ const SearchItemWrapper: React.FC = ({ ids, renderItem, header }) => { return ( <> - {header} - {renderItem(data)} + {header} + + {data.map((item) => renderItem(item))} + ); }; diff --git a/app/(auth)/play.tsx b/app/(auth)/play.tsx index 0d3b3891..5ca25b2b 100644 --- a/app/(auth)/play.tsx +++ b/app/(auth)/play.tsx @@ -1,10 +1,10 @@ import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer"; import { useSettings } from "@/utils/atoms/settings"; import * as NavigationBar from "expo-navigation-bar"; -import { StatusBar } from "expo-status-bar"; -import { useEffect, useState } from "react"; -import { Platform, View, ViewProps } from "react-native"; import * as ScreenOrientation from "expo-screen-orientation"; +import { StatusBar } from "expo-status-bar"; +import { useEffect } from "react"; +import { Platform, View, ViewProps } from "react-native"; interface Props extends ViewProps {} diff --git a/bun.lockb b/bun.lockb index 36ba3d5e..c717c667 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/FullScreenVideoPlayer.tsx b/components/FullScreenVideoPlayer.tsx index ef762299..d19a65eb 100644 --- a/components/FullScreenVideoPlayer.tsx +++ b/components/FullScreenVideoPlayer.tsx @@ -87,14 +87,29 @@ export const FullScreenVideoPlayer: React.FC = () => { }); useEffect(() => { - const subscription = Dimensions.addEventListener( + const dimensionsSubscription = Dimensions.addEventListener( "change", ({ window, screen }) => { setDimensions({ window, screen }); } ); - return () => subscription?.remove(); - }); + + const orientationSubscription = + ScreenOrientation.addOrientationChangeListener((event) => { + setOrientation( + orientationToOrientationLock(event.orientationInfo.orientation) + ); + }); + + ScreenOrientation.getOrientationAsync().then((orientation) => { + setOrientation(orientationToOrientationLock(orientation)); + }); + + return () => { + dimensionsSubscription.remove(); + orientationSubscription.remove(); + }; + }, []); const from = useMemo(() => segments[2], [segments]); @@ -165,24 +180,6 @@ export const FullScreenVideoPlayer: React.FC = () => { return () => backHandler.remove(); }, [currentlyPlaying, stopPlayback, router]); - useEffect(() => { - const subscription = ScreenOrientation.addOrientationChangeListener( - (event) => { - setOrientation( - orientationToOrientationLock(event.orientationInfo.orientation) - ); - } - ); - - ScreenOrientation.getOrientationAsync().then((orientation) => { - setOrientation(orientationToOrientationLock(orientation)); - }); - - return () => { - subscription.remove(); - }; - }, []); - const isLandscape = useMemo(() => { return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT || orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 30d936cf..47f97e9a 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -169,7 +169,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { if (item?.Type === "Episode") headerHeightRef.current = 400; else if (item?.Type === "Movie") headerHeightRef.current = 500; else headerHeightRef.current = 400; - }, [item]); + }, [item, orientation]); const { data: sessionData } = useQuery({ queryKey: ["sessionData", item?.Id], diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index e8485627..2f68689f 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -19,6 +19,9 @@ import { } from "react-native"; import { toast } from "sonner-native"; import { Button } from "../Button"; +import { Image } from "expo-image"; +import { useMemo } from "react"; +import { storage } from "@/utils/mmkv"; interface Props extends ViewProps {} @@ -95,6 +98,10 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { return formatTimeString(timeLeft, true); }; + const base64Image = useMemo(() => { + return storage.getString(process.item.Id!); + }, []); + return ( router.push(`/(auth)/items/page?id=${process.item.Id}`)} @@ -114,15 +121,29 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { }} > )} - - - + + + {base64Image && ( + + + + )} + {process.item.Type} {process.item.Name} {process.item.ProductionYear} - + {process.progress === 0 ? ( ) : ( @@ -143,6 +164,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { cancelJobMutation.mutate(process.id)} + className="ml-auto" > {cancelJobMutation.isPending ? ( diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx index 456563db..dc867516 100644 --- a/components/downloads/EpisodeCard.tsx +++ b/components/downloads/EpisodeCard.tsx @@ -1,7 +1,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import * as Haptics from "expo-haptics"; -import React, { useCallback, useRef } from "react"; -import { TouchableOpacity } from "react-native"; +import React, { useCallback, useMemo, useRef } from "react"; +import { TouchableOpacity, View } from "react-native"; import { ActionSheetProvider, useActionSheet, @@ -10,6 +10,10 @@ import { import { useFileOpener } from "@/hooks/useDownloadedFileOpener"; import { Text } from "../common/Text"; import { useDownload } from "@/providers/DownloadProvider"; +import { storage } from "@/utils/mmkv"; +import { Image } from "expo-image"; +import { ItemCardText } from "../ItemCardText"; +import { Ionicons } from "@expo/vector-icons"; interface EpisodeCardProps { item: BaseItemDto; @@ -25,6 +29,10 @@ export const EpisodeCard: React.FC = ({ item }) => { const { openFile } = useFileOpener(); const { showActionSheetWithOptions } = useActionSheet(); + const base64Image = useMemo(() => { + return storage.getString(item.Id!); + }, []); + const handleOpenFile = useCallback(() => { openFile(item); }, [item, openFile]); @@ -68,10 +76,32 @@ export const EpisodeCard: React.FC = ({ item }) => { - {item.Name} - Episode {item.IndexNumber} + {base64Image ? ( + + + + ) : ( + + + + )} + ); }; diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx index 8be14bf8..54381c08 100644 --- a/components/downloads/MovieCard.tsx +++ b/components/downloads/MovieCard.tsx @@ -1,6 +1,6 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import * as Haptics from "expo-haptics"; -import React, { useCallback } from "react"; +import React, { useCallback, useMemo } from "react"; import { TouchableOpacity, View } from "react-native"; import { ActionSheetProvider, @@ -12,6 +12,10 @@ import { Text } from "../common/Text"; import { useFileOpener } from "@/hooks/useDownloadedFileOpener"; import { useDownload } from "@/providers/DownloadProvider"; +import { storage } from "@/utils/mmkv"; +import { Image } from "expo-image"; +import { Ionicons } from "@expo/vector-icons"; +import { ItemCardText } from "../ItemCardText"; interface MovieCardProps { item: BaseItemDto; @@ -31,6 +35,10 @@ export const MovieCard: React.FC = ({ item }) => { openFile(item); }, [item, openFile]); + const base64Image = useMemo(() => { + return storage.getString(item.Id!); + }, []); + /** * Handles deleting the file with haptic feedback. */ @@ -67,18 +75,31 @@ export const MovieCard: React.FC = ({ item }) => { }, [showActionSheetWithOptions, handleDeleteFile]); return ( - - {item.Name} - - {item.ProductionYear} - - {runtimeTicksToMinutes(item.RunTimeTicks)} - - + + {base64Image ? ( + + + + ) : ( + + + + )} + ); }; diff --git a/components/downloads/SeriesCard.tsx b/components/downloads/SeriesCard.tsx index bd057a0b..5fe67611 100644 --- a/components/downloads/SeriesCard.tsx +++ b/components/downloads/SeriesCard.tsx @@ -1,5 +1,5 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { View } from "react-native"; +import { ScrollView, View } from "react-native"; import { EpisodeCard } from "./EpisodeCard"; import { Text } from "../common/Text"; import { useMemo } from "react"; @@ -22,26 +22,32 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => { ); }, [items]); + const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => { + return a.IndexNumber! > b.IndexNumber! ? 1 : -1; + }; + return ( - + {items[0].SeriesName} {items.length} - TV-Series + TV-Series {groupBySeason.map((seasonItems, seasonIndex) => ( - + {seasonItems[0].SeasonName} - {seasonItems.map((item, index) => ( - - + + + {seasonItems.sort(sortByIndex)?.map((item, index) => ( + + ))} - ))} + ))} diff --git a/hooks/useImageStorage.ts b/hooks/useImageStorage.ts new file mode 100644 index 00000000..ad271f07 --- /dev/null +++ b/hooks/useImageStorage.ts @@ -0,0 +1,89 @@ +import { useState, useCallback } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as FileSystem from "expo-file-system"; +import { storage } from "@/utils/mmkv"; + +const useImageStorage = () => { + const saveBase64Image = useCallback(async (base64: string, key: string) => { + try { + // Save the base64 string to AsyncStorage + storage.set(key, base64); + console.log("Image saved successfully"); + } catch (error) { + console.error("Error saving image:", error); + throw error; + } + }, []); + + const image2Base64 = useCallback(async (url?: string | null) => { + if (!url) return null; + + let blob: Blob; + try { + // Fetch the data from the URL + const response = await fetch(url); + blob = await response.blob(); + } catch (error) { + console.warn("Error fetching image:", error); + return null; + } + + // Create a FileReader instance + const reader = new FileReader(); + + // Convert blob to base64 + return new Promise((resolve, reject) => { + reader.onloadend = () => { + if (typeof reader.result === "string") { + // Extract the base64 string (remove the data URL prefix) + const base64 = reader.result.split(",")[1]; + resolve(base64); + } else { + reject(new Error("Failed to convert image to base64")); + } + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + }, []); + + const saveImage = useCallback( + async (key?: string | null, imageUrl?: string | null) => { + if (!imageUrl || !key) { + console.warn("Invalid image URL or key"); + return; + } + + try { + const base64Image = await image2Base64(imageUrl); + if (!base64Image || base64Image.length === 0) { + console.warn("Failed to convert image to base64"); + return; + } + saveBase64Image(base64Image, key); + } catch (error) { + console.warn("Error saving image:", error); + } + }, + [] + ); + + const loadImage = useCallback(async (key: string) => { + try { + // Retrieve the base64 string from AsyncStorage + const base64Image = storage.getString(key); + if (base64Image !== null) { + // Set the loaded image state + return `data:image/jpeg;base64,${base64Image}`; + } + return null; + } catch (error) { + console.error("Error loading image:", error); + throw error; + } + }, []); + + return { saveImage, loadImage, saveBase64Image, image2Base64 }; +}; + +export default useImageStorage; diff --git a/package.json b/package.json index f8c73f04..ea67341b 100644 --- a/package.json +++ b/package.json @@ -61,21 +61,21 @@ "nativewind": "^2.0.11", "react": "18.2.0", "react-dom": "18.2.0", - "react-native": "0.74.5", + "react-native": "~0.75.0", "react-native-awesome-slider": "^2.5.3", "react-native-circular-progress": "^1.4.0", "react-native-compressor": "^1.8.25", - "react-native-gesture-handler": "~2.16.1", + "react-native-gesture-handler": "~2.18.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.1", "react-native-ios-utilities": "^4.4.5", - "react-native-mmkv": "^3.0.2", - "react-native-reanimated": "~3.10.1", + "react-native-mmkv": "^2.12.2", + "react-native-reanimated": "~3.15.0", "react-native-reanimated-carousel": "4.0.0-canary.15", "react-native-safe-area-context": "4.10.5", - "react-native-screens": "3.31.1", + "react-native-screens": "~3.34.0", "react-native-svg": "15.2.0", "react-native-url-polyfill": "^2.0.0", "react-native-uuid": "^2.0.2", @@ -98,5 +98,15 @@ "react-test-renderer": "18.2.0", "typescript": "~5.3.3" }, - "private": true + "private": true, + "expo": { + "install": { + "exclude": [ + "react-native@~0.74.0", + "react-native-reanimated@~3.10.0", + "react-native-gesture-handler@~2.16.1", + "react-native-screens@~3.31.1" + ] + } + } } diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 0ee942e8..1e342994 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -38,6 +38,8 @@ import { AppState, AppStateStatus } from "react-native"; import { toast } from "sonner-native"; import { apiAtom } from "./JellyfinProvider"; import * as Notifications from "expo-notifications"; +import { getItemImage } from "@/utils/getItemImage"; +import useImageStorage from "@/hooks/useImageStorage"; function onAppStateChange(status: AppStateStatus) { focusManager.setFocused(status === "active"); @@ -53,6 +55,9 @@ function useDownloadProvider() { const router = useRouter(); const [api] = useAtom(apiAtom); + const { loadImage, saveImage, image2Base64, saveBase64Image } = + useImageStorage(); + const [processes, setProcesses] = useState([]); const authHeader = useMemo(() => { @@ -294,11 +299,30 @@ function useDownloadProvider() { const startBackgroundDownload = useCallback( async (url: string, item: BaseItemDto, fileExtension: string) => { + if (!api || !item.Id || !authHeader) + throw new Error("startBackgroundDownload ~ Missing required params"); + try { const deviceId = await getOrSetDeviceId(); + const itemImage = getItemImage({ + item, + api, + variant: "Primary", + quality: 90, + width: 500, + }); + + await saveImage(item.Id, itemImage?.uri); + const response = await axios.post( settings?.optimizedVersionsServerUrl + "optimize-version", - { url, fileExtension, deviceId, itemId: item.Id, item }, + { + url, + fileExtension, + deviceId, + itemId: item.Id, + item, + }, { headers: { "Content-Type": "application/json", diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index 00b4d284..f3569a03 100644 --- a/providers/PlaybackProvider.tsx +++ b/providers/PlaybackProvider.tsx @@ -11,6 +11,7 @@ import React, { import { useSettings } from "@/utils/atoms/settings"; import { getDeviceId } from "@/utils/device"; +import { SubtitleTrack } from "@/utils/hls/parseM3U8ForSubtitles"; import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress"; import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped"; import { postCapabilities } from "@/utils/jellyfin/session/capabilities"; @@ -20,16 +21,12 @@ import { } from "@jellyfin/sdk/lib/generated-client/models"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; import * as Linking from "expo-linking"; +import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import { debounce } from "lodash"; import { Alert } from "react-native"; import { OnProgressData, type VideoRef } from "react-native-video"; import { apiAtom, userAtom } from "./JellyfinProvider"; -import { - parseM3U8ForSubtitles, - SubtitleTrack, -} from "@/utils/hls/parseM3U8ForSubtitles"; -import { useRouter } from "expo-router"; export type CurrentlyPlayingState = { url: string; diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts index 443bdf59..b7bb10fe 100644 --- a/utils/optimize-server.ts +++ b/utils/optimize-server.ts @@ -25,6 +25,7 @@ export interface JobStatus { item: Partial; speed?: number; timestamp: Date; + base64Image?: string; } /**