From d4d3cbbc43558235d18548a998e4ae821270d643 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 6 Aug 2024 23:53:00 +0200 Subject: [PATCH] chore: refactor --- app/(auth)/(tabs)/index.tsx | 69 +-- app/(auth)/(tabs)/search.tsx | 24 +- app/(auth)/collections/[collection]/page.tsx | 23 +- app/(auth)/items/[id]/page.tsx | 11 +- app/(auth)/series/[id]/page.tsx | 13 +- app/(auth)/settings.tsx | 8 +- components/DownloadItem.tsx | 46 +- components/PlayedStatus.tsx | 3 +- components/VideoPlayer.tsx | 167 ++---- components/downloads/EpisodeCard.tsx | 102 ++-- components/downloads/MovieCard.tsx | 114 ++-- components/series/NextUp.tsx | 19 +- hooks/useDownloadMedia.ts | 117 ++++ hooks/useFiles.ts | 84 +++ hooks/useRemuxHlsToMp4.ts | 127 +++++ utils/device-profiles.ts | 477 ---------------- utils/files/useFiles.ts | 63 --- utils/jellyfin.ts | 528 +----------------- utils/jellyfin/image/getPrimaryImage.ts | 18 + utils/jellyfin/items/getUserItemData.ts | 46 ++ utils/jellyfin/jellyfin.ts | 8 + utils/jellyfin/playstate/markAsNotPlayed.ts | 47 ++ utils/jellyfin/playstate/markAsPlayed.ts | 50 ++ .../playstate/reportPlaybackProgress.ts | 44 ++ .../playstate/reportPlaybackStopped.ts | 61 ++ utils/jellyfin/tvshows/nextUp.ts | 45 ++ utils/profiles/chromecast.ts | 130 +++++ utils/video/createVideoUrl.ts | 76 --- 28 files changed, 1075 insertions(+), 1445 deletions(-) create mode 100644 hooks/useDownloadMedia.ts create mode 100644 hooks/useFiles.ts create mode 100644 hooks/useRemuxHlsToMp4.ts delete mode 100644 utils/device-profiles.ts delete mode 100644 utils/files/useFiles.ts create mode 100644 utils/jellyfin/image/getPrimaryImage.ts create mode 100644 utils/jellyfin/items/getUserItemData.ts create mode 100644 utils/jellyfin/jellyfin.ts create mode 100644 utils/jellyfin/playstate/markAsNotPlayed.ts create mode 100644 utils/jellyfin/playstate/markAsPlayed.ts create mode 100644 utils/jellyfin/playstate/reportPlaybackProgress.ts create mode 100644 utils/jellyfin/playstate/reportPlaybackStopped.ts create mode 100644 utils/jellyfin/tvshows/nextUp.ts create mode 100644 utils/profiles/chromecast.ts delete mode 100644 utils/video/createVideoUrl.ts diff --git a/app/(auth)/(tabs)/index.tsx b/app/(auth)/(tabs)/index.tsx index 0603dacd..c885934e 100644 --- a/app/(auth)/(tabs)/index.tsx +++ b/app/(auth)/(tabs)/index.tsx @@ -3,9 +3,12 @@ import { Text } from "@/components/common/Text"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import { ItemCardText } from "@/components/ItemCardText"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { nextUp } from "@/utils/jellyfin"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { getItemsApi, getSuggestionsApi } from "@jellyfin/sdk/lib/utils/api"; +import { + getItemsApi, + getSuggestionsApi, + getTvShowsApi, +} from "@jellyfin/sdk/lib/utils/api"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import { useAtom } from "jotai"; @@ -19,22 +22,24 @@ import { } from "react-native"; export default function index() { + const router = useRouter(); + const queryClient = useQueryClient(); + const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const router = useRouter(); + + const [loading, setLoading] = useState(false); const { data, isLoading, isError } = useQuery({ queryKey: ["resumeItems", user?.Id], - queryFn: async () => { - if (!api || !user?.Id) { - return []; - } - - const response = await getItemsApi(api).getResumeItems({ - userId: user.Id, - }); - return response.data.Items || []; - }, + queryFn: async () => + (api && + ( + await getItemsApi(api).getResumeItems({ + userId: user?.Id, + }) + ).data.Items) || + [], enabled: !!api && !!user?.Id, staleTime: 60, }); @@ -42,10 +47,14 @@ export default function index() { const { data: _nextUpData } = useQuery({ queryKey: ["nextUp-all", user?.Id], queryFn: async () => - await nextUp({ - userId: user?.Id, - api, - }), + (api && + ( + await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + fields: ["MediaSourceCount"], + }) + ).data.Items) || + [], enabled: !!api && !!user?.Id, staleTime: 0, }); @@ -87,26 +96,20 @@ export default function index() { const { data: suggestions } = useQuery({ queryKey: ["suggestions", user?.Id], - queryFn: async () => { - if (!api || !user?.Id) { - return []; - } - const response = await getSuggestionsApi(api).getSuggestions({ - userId: user.Id, - limit: 5, - mediaType: ["Video"], - }); - - return response.data.Items || []; - }, + queryFn: async () => + (api && + ( + await getSuggestionsApi(api).getSuggestions({ + userId: user?.Id, + limit: 5, + mediaType: ["Video"], + }) + ).data.Items) || + [], enabled: !!api && !!user?.Id, staleTime: 60, }); - const queryClient = useQueryClient(); - - const [loading, setLoading] = useState(false); - const refetch = useCallback(async () => { setLoading(true); await queryClient.refetchQueries({ queryKey: ["resumeItems", user?.Id] }); diff --git a/app/(auth)/(tabs)/search.tsx b/app/(auth)/(tabs)/search.tsx index 28852112..dc88ba89 100644 --- a/app/(auth)/(tabs)/search.tsx +++ b/app/(auth)/(tabs)/search.tsx @@ -6,7 +6,8 @@ import { ItemCardText } from "@/components/ItemCardText"; import MoviePoster from "@/components/MoviePoster"; import Poster from "@/components/Poster"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { getPrimaryImage, getUserItemData } from "@/utils/jellyfin"; +import { getPrimaryImage } from "@/utils/jellyfin"; +import { getUserItemData } from "@/utils/jellyfin/items/getUserItemData"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getSearchApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; @@ -146,21 +147,6 @@ export default function search() { /> )} /> - - {/* Series - - } - /> - - Episodes - ( - - )} - /> */} ); @@ -187,13 +173,15 @@ const SearchItemWrapper: React.FC = ({ ids, renderItem }) => { api, userId: user.Id, itemId: id, - }) + }), ); const results = await Promise.all(itemPromises); // Filter out null items - return results.filter((item) => item !== null); + return results.filter( + (item) => item !== null, + ) as unknown as BaseItemDto[]; }, enabled: !!ids && ids.length > 0 && !!api && !!user?.Id, staleTime: Infinity, diff --git a/app/(auth)/collections/[collection]/page.tsx b/app/(auth)/collections/[collection]/page.tsx index 7907e624..f31ee2a7 100644 --- a/app/(auth)/collections/[collection]/page.tsx +++ b/app/(auth)/collections/[collection]/page.tsx @@ -25,19 +25,14 @@ const page: React.FC = () => { const { data: collection } = useQuery({ queryKey: ["collection", collectionId], - queryFn: async () => { - if (!api || !user?.Id) { - return null; - } - - const data = ( - await getItemsApi(api).getItems({ - userId: user.Id, - }) - ).data; - - return data.Items?.find((item) => item.Id == collectionId); - }, + queryFn: async () => + (api && + ( + await getItemsApi(api).getItems({ + userId: user?.Id, + }) + ).data.Items?.find((item) => item.Id == collectionId)) || + null, enabled: !!api && !!user?.Id, staleTime: 0, }); @@ -77,7 +72,7 @@ const page: React.FC = () => { headers: { Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, }, - } + }, ); return response.data || []; diff --git a/app/(auth)/items/[id]/page.tsx b/app/(auth)/items/[id]/page.tsx index 94c55938..b407ffd2 100644 --- a/app/(auth)/items/[id]/page.tsx +++ b/app/(auth)/items/[id]/page.tsx @@ -7,11 +7,7 @@ import { CurrentSeries } from "@/components/series/CurrentSeries"; import { SimilarItems } from "@/components/SimilarItems"; import { VideoPlayer } from "@/components/VideoPlayer"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { - getBackdrop, - getLogoImageById, - getUserItemData, -} from "@/utils/jellyfin"; +import { getBackdrop, getLogoImageById } from "@/utils/jellyfin"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { router, useLocalSearchParams } from "expo-router"; @@ -24,6 +20,7 @@ import { View, } from "react-native"; import { ParallaxScrollView } from "../../../../components/ParallaxPage"; +import { getUserItemData } from "@/utils/jellyfin/items/getUserItemData"; const page: React.FC = () => { const local = useLocalSearchParams(); @@ -54,12 +51,12 @@ const page: React.FC = () => { quality: 90, width: 1000, }), - [item] + [item], ); const logoUrl = useMemo( () => (item?.Type === "Movie" ? getLogoImageById({ api, item }) : null), - [item] + [item], ); if (l1) diff --git a/app/(auth)/series/[id]/page.tsx b/app/(auth)/series/[id]/page.tsx index 792e0339..04f35321 100644 --- a/app/(auth)/series/[id]/page.tsx +++ b/app/(auth)/series/[id]/page.tsx @@ -3,13 +3,8 @@ import { ParallaxScrollView } from "@/components/ParallaxPage"; import { NextUp } from "@/components/series/NextUp"; import { SeasonPicker } from "@/components/series/SeasonPicker"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { - getBackdrop, - getLogoImageById, - getPrimaryImage, - getPrimaryImageById, - getUserItemData, -} from "@/utils/jellyfin"; +import { getBackdrop, getLogoImageById } from "@/utils/jellyfin"; +import { getUserItemData } from "@/utils/jellyfin/items/getUserItemData"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useLocalSearchParams } from "expo-router"; @@ -44,7 +39,7 @@ const page: React.FC = () => { quality: 90, width: 1000, }), - [item] + [item], ); const logoUrl = useMemo( @@ -53,7 +48,7 @@ const page: React.FC = () => { api, item, }), - [item] + [item], ); if (!item || !backdropUrl) return null; diff --git a/app/(auth)/settings.tsx b/app/(auth)/settings.tsx index b970cdd8..696a037e 100644 --- a/app/(auth)/settings.tsx +++ b/app/(auth)/settings.tsx @@ -2,13 +2,13 @@ import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { ListItem } from "@/components/ListItem"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; -import { useFiles } from "@/utils/files/useFiles"; import { readFromLog } from "@/utils/log"; import { useQuery } from "@tanstack/react-query"; import * as FileSystem from "expo-file-system"; import { useAtom } from "jotai"; import { ScrollView, View } from "react-native"; -import * as Haptics from "expo-haptics"; +import * as Haptics from "expo-haptics"; +import { useFiles } from "@/hooks/useFiles"; export default function settings() { const { logout } = useJellyfin(); @@ -39,7 +39,9 @@ export default function settings() { color="red" onPress={async () => { await deleteAllFiles(); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) + Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Success, + ); }} > Delete all downloaded files diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 40dc9159..83936afd 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -1,10 +1,5 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { runningProcesses } from "@/utils/atoms/downloads"; -import { - getPlaybackInfo, - useDownloadMedia, - useRemuxHlsToMp4, -} from "@/utils/jellyfin"; import Ionicons from "@expo/vector-icons/Ionicons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import AsyncStorage from "@react-native-async-storage/async-storage"; @@ -15,6 +10,9 @@ import { useCallback, useEffect, useState } from "react"; 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/items/getUserItemData"; type DownloadProps = { item: BaseItemDto; @@ -50,7 +48,7 @@ export const DownloadItem: React.FC = ({ downloadMedia(item); } else { throw new Error( - "Direct play not supported thus the file cannot be downloaded" + "Direct play not supported thus the file cannot be downloaded", ); } }, [item, user, playbackInfo]); @@ -60,7 +58,7 @@ export const DownloadItem: React.FC = ({ useEffect(() => { (async () => { const data: BaseItemDto[] = JSON.parse( - (await AsyncStorage.getItem("downloaded_files")) || "[]" + (await AsyncStorage.getItem("downloaded_files")) || "[]", ); if (data.find((d) => d.Id === item.Id)) setDownloaded(true); @@ -96,26 +94,28 @@ export const DownloadItem: React.FC = ({ }} className="flex flex-row items-center" > - - - - - {process.progress > 0 ? ( + {process.progress === 0 ? ( + + ) : ( + + + + - + {process.progress.toFixed(0)}% - ) : null} - + + )} - {process?.speed && (process?.speed || 0) > 0 ? ( + {process?.speed && process.speed > 0 ? ( {process.speed.toFixed(2)}x @@ -125,7 +125,7 @@ export const DownloadItem: React.FC = ({ { router.push( - `/(auth)/player/offline/page?url=${item.Id}.mp4&itemId=${item.Id}` + `/(auth)/player/offline/page?url=${item.Id}.mp4&itemId=${item.Id}`, ); }} > diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx index 09923079..6d4a0c30 100644 --- a/components/PlayedStatus.tsx +++ b/components/PlayedStatus.tsx @@ -1,5 +1,4 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { markAsNotPlayed, markAsPlayed } from "@/utils/jellyfin"; import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useQueryClient, InvalidateQueryFilters } from "@tanstack/react-query"; @@ -7,6 +6,8 @@ import { useAtom } from "jotai"; import React, { useCallback } from "react"; import { TouchableOpacity, View } from "react-native"; import * as Haptics from "expo-haptics"; +import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed"; +import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed"; export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => { const [api] = useAtom(apiAtom); diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx index 675c4fa9..be81532e 100644 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -1,15 +1,4 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { - chromecastProfile, - iosProfile, - iOSProfile_2, -} from "@/utils/device-profiles"; -import { - getStreamUrl, - getUserItemData, - reportPlaybackProgress, - reportPlaybackStopped, -} from "@/utils/jellyfin"; import { runtimeTicksToMinutes } from "@/utils/time"; import { Feather, Ionicons } from "@expo/vector-icons"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; @@ -34,8 +23,12 @@ import Video, { import * as DropdownMenu from "zeego/dropdown-menu"; import { Button } from "./Button"; import { Text } from "./common/Text"; -import iosFmp4 from "../utils/profiles/iosFmp4"; import ios12 from "../utils/profiles/ios12"; +import { getUserItemData } from "@/utils/jellyfin/items/getUserItemData"; +import { getStreamUrl } from "@/utils/jellyfin"; +import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress"; +import { chromecastProfile } from "@/utils/profiles/chromecast"; +import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped"; type VideoPlayerProps = { itemId: string; @@ -75,7 +68,6 @@ export const VideoPlayer: React.FC = ({ const castDevice = useCastDevice(); const client = useRemoteMediaClient(); - const queryClient = useQueryClient(); const { data: item } = useQuery({ @@ -130,40 +122,20 @@ export const VideoPlayer: React.FC = ({ }); const onProgress = useCallback( - ({ currentTime, playableDuration, seekableDuration }: OnProgressData) => { - if (!currentTime || !sessionData?.PlaySessionId) return; - if (paused) return; - + ({ currentTime }: OnProgressData) => { + if (!currentTime || !sessionData?.PlaySessionId || paused) return; const newProgress = currentTime * 10000000; setProgress(newProgress); reportPlaybackProgress({ api, - itemId: itemId, + itemId, positionTicks: newProgress, sessionId: sessionData.PlaySessionId, }); }, - [sessionData?.PlaySessionId, item, api, paused] + [sessionData?.PlaySessionId, item, api, paused], ); - const onSeek = ({ - currentTime, - seekTime, - }: { - currentTime: number; - seekTime: number; - }) => { - // console.log("Seek to time: ", seekTime); - }; - - const onError = (error: OnVideoErrorData) => { - console.log("Video Error: ", JSON.stringify(error.error)); - }; - - const onBuffer = (error: OnBufferData) => { - console.log("Video buffering: ", error.isBuffering); - }; - const play = () => { if (videoRef.current) { videoRef.current.resume(); @@ -171,114 +143,90 @@ export const VideoPlayer: React.FC = ({ } }; - const startPosition = useMemo(() => { - return Math.round((item?.UserData?.PlaybackPositionTicks || 0) / 10000); - }, [item]); + const pause = useCallback(() => { + videoRef.current?.pause(); + setPaused(true); - const [hidePlayer, setHidePlayer] = useState(true); + if (progress > 0) + reportPlaybackStopped({ + api, + itemId: item?.Id, + positionTicks: progress, + sessionId: sessionData?.PlaySessionId, + }); - const enableVideo = useMemo(() => { - return ( - playbackURL !== undefined && - item !== undefined && - item !== null && - startPosition !== undefined && - sessionData !== undefined - ); - }, [playbackURL, item, startPosition, sessionData]); + 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) + : null, + [item], + ); + + const enableVideo = useMemo( + () => !!(playbackURL && item && startPosition !== null && sessionData), + [playbackURL, item, startPosition, sessionData], + ); + + const chromecastReady = useMemo( + () => !!(castDevice?.deviceId && item), + [castDevice, item], + ); const cast = useCallback(() => { - if (client === null) { - console.log("no client "); - return; - } - - if (!playbackURL) { - console.log("no playback url"); - return; - } - - if (!item) { - console.log("no item"); - return; - } - + if (!client || !playbackURL || !item) return; client.loadMedia({ mediaInfo: { contentUrl: playbackURL, contentType: "video/mp4", metadata: { - type: item?.Type === "Episode" ? "tvShow" : "movie", - title: item?.Name || "", - subtitle: item?.Overview || "", + type: item.Type === "Episode" ? "tvShow" : "movie", + title: item.Name || "", + subtitle: item.Overview || "", }, - streamDuration: Math.floor((item?.RunTimeTicks || 0) / 10000), + streamDuration: Math.floor((item.RunTimeTicks || 0) / 10000), }, startTime: Math.floor( - (item?.UserData?.PlaybackPositionTicks || 0) / 10000 + (item.UserData?.PlaybackPositionTicks || 0) / 10000, ), }); }, [item, client, playbackURL]); useEffect(() => { - if (videoRef.current) { - videoRef.current.pause(); - } + videoRef.current?.pause(); }, []); - const chromecastReady = useMemo(() => { - return castDevice?.deviceId && item; - }, [castDevice, item]); - return ( - {enableVideo === true ? ( + {enableVideo === true && startPosition !== null && !!playbackURL ? (