diff --git a/app.json b/app.json index 4f698757..e4ce296b 100644 --- a/app.json +++ b/app.json @@ -39,6 +39,7 @@ "expo-font", "expo-video", "react-native-compressor", + // "react-native-google-cast", [ "react-native-video", { diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index b373ade5..e9630ff1 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -19,7 +19,6 @@ export default function TabLayout() { options={{ headerShown: true, headerStyle: { backgroundColor: "black" }, - title: "Home", tabBarIcon: ({ color, focused }) => ( { + if ( + order.indexOf(a.CollectionType!) < order.indexOf(b.CollectionType!) + ) { + return 1; + } + + return -1; + }); + return data.Items || []; }, enabled: !!api && !!user?.Id, @@ -85,56 +100,53 @@ export default function index() { return ( - - Continue Watching - - - {data.map((item) => ( - router.push(`/items/${item.Id}/page`)} - className="flex flex-col w-48" - > - - - - - - ))} - - - Collections - - - {collections?.map((item) => ( - router.push(`/collections/${item.Id}/page`)} - className="flex flex-col w-48" - > - - - - - - ))} - - - Suggestions - - - {suggestions?.map((item) => ( - router.push(`/items/${item.Id}/page`)} - className="flex flex-col w-48" - > + + Continue Watching + + data={data} + renderItem={(item, index) => ( + router.push(`/items/${item.Id}/page`)} + className="flex flex-col w-48" + > + - - ))} - - + + + )} + /> + Collections + + data={collections} + renderItem={(item, index) => ( + router.push(`/collections/${item.Id}/page`)} + className="flex flex-col w-48" + > + + + + + + )} + /> + Suggestions + + data={suggestions} + renderItem={(item, index) => ( + router.push(`/items/${item.Id}/page`)} + className="flex flex-col w-48" + > + + + + )} + /> ); diff --git a/app/(auth)/(tabs)/search.tsx b/app/(auth)/(tabs)/search.tsx index 8afbebe8..62410f29 100644 --- a/app/(auth)/(tabs)/search.tsx +++ b/app/(auth)/(tabs)/search.tsx @@ -1,7 +1,12 @@ +import { HorizontalScroll } from "@/components/common/HorrizontalScroll"; import { Input } from "@/components/common/Input"; import { Text } from "@/components/common/Text"; +import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; +import { ItemCardText } from "@/components/ItemCardText"; +import MoviePoster from "@/components/MoviePoster"; +import Poster from "@/components/Poster"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { Ionicons } from "@expo/vector-icons"; +import { getUserItemData } from "@/utils/jellyfin"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getSearchApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; @@ -12,29 +17,12 @@ import { ScrollView, TouchableOpacity, View } from "react-native"; export default function search() { const [search, setSearch] = useState(""); - const [totalResults, setTotalResults] = useState(0); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - // useEffect(() => { - // (async () => { - // if (!api || search.length === 0) return; - // const searchApi = await getSearchApi(api).getSearchHints({ - // searchTerm: search, - // limit: 10, - // includeItemTypes: ["Movie"], - // }); - - // const data = searchApi.data; - - // setTotalResults(data.TotalRecordCount || 0); - // setData(data.SearchHints || []); - // })(); - // }, [search]); - - const { data } = useQuery({ - queryKey: ["search", search], + const { data: movies } = useQuery({ + queryKey: ["search-movies", search], queryFn: async () => { if (!api || !user || search.length === 0) return []; @@ -48,44 +36,166 @@ export default function search() { }, }); - return ( - - - setSearch(text)} - /> - + const { data: series } = useQuery({ + queryKey: ["search-series", search], + queryFn: async () => { + if (!api || !user || search.length === 0) return []; - - - {data?.map((item, index) => ( - - ))} + const searchApi = await getSearchApi(api).getSearchHints({ + searchTerm: search, + limit: 10, + includeItemTypes: ["Series"], + }); + + return searchApi.data.SearchHints; + }, + }); + const { data: episodes } = useQuery({ + queryKey: ["search-episodes", search], + queryFn: async () => { + if (!api || !user || search.length === 0) return []; + + const searchApi = await getSearchApi(api).getSearchHints({ + searchTerm: search, + limit: 10, + includeItemTypes: ["Episode"], + }); + + return searchApi.data.SearchHints; + }, + }); + + return ( + + + + setSearch(text)} + /> - - + + Movies + m.Id!)} + renderItem={(data) => ( + + data={data} + renderItem={(item) => ( + router.push(`/items/${item.Id}/page`)} + > + + {item.Name} + + {item.ProductionYear} + + + )} + /> + )} + /> + Series + m.Id!)} + renderItem={(data) => ( + + data={data} + renderItem={(item) => ( + router.push(`/series/${item.Id}/page`)} + className="flex flex-col w-32" + > + + {item.Name} + + {item.ProductionYear} + + + )} + /> + )} + /> + Episodes + m.Id!)} + renderItem={(data) => ( + + data={data} + renderItem={(item) => ( + router.push(`/items/${item.Id}/page`)} + className="flex flex-col w-48" + > + + + + )} + /> + )} + /> + + {/* Series + + } + /> + + Episodes + ( + + )} + /> */} + + ); } -type RenderItemProps = { - item: BaseItemDto; +type Props = { + ids?: string[] | null; + renderItem: (data: BaseItemDto[]) => React.ReactNode; }; -const RenderItem: React.FC = ({ item }) => { - return ( - router.push(`/(auth)/items/${item.Id}/page`)} - className="flex flex-row items-center justify-between p-4 bg-neutral-900 border-neutral-800" - > - - {item.Name} - {item.Type === "Movie" && ( - {item.ProductionYear} - )} - - - - ); +const SearchItemWrapper: React.FC = ({ ids, renderItem }) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const { data, isLoading: l1 } = useQuery({ + queryKey: ["items", ids], + queryFn: async () => { + if (!user?.Id || !api || !ids || ids.length === 0) { + return []; + } + + const itemPromises = ids.map((id) => + getUserItemData({ + api, + userId: user.Id, + itemId: id, + }) + ); + + const results = await Promise.all(itemPromises); + + // Filter out null items + return results.filter((item) => item !== null); + }, + enabled: !!ids && ids.length > 0 && !!api && !!user?.Id, + staleTime: Infinity, + }); + + if (!data) return No results; + + return renderItem(data); }; diff --git a/app/(auth)/collections/[collection]/page.tsx b/app/(auth)/collections/[collection]/page.tsx index 9b5bf370..7907e624 100644 --- a/app/(auth)/collections/[collection]/page.tsx +++ b/app/(auth)/collections/[collection]/page.tsx @@ -36,8 +36,6 @@ const page: React.FC = () => { }) ).data; - console.log(data.Items?.find((item) => item.Id == collectionId)); - return data.Items?.find((item) => item.Id == collectionId); }, enabled: !!api && !!user?.Id, @@ -143,6 +141,8 @@ const page: React.FC = () => { onPress={() => { if (collection?.CollectionType === "movies") { router.push(`/items/${item.Id}/page`); + } else if (collection?.CollectionType === "tvshows") { + router.push(`/series/${item.Id}/page`); } }} > diff --git a/app/(auth)/items/[id]/page.tsx b/app/(auth)/items/[id]/page.tsx index a386c275..1c85f01f 100644 --- a/app/(auth)/items/[id]/page.tsx +++ b/app/(auth)/items/[id]/page.tsx @@ -1,5 +1,9 @@ +import { LargePoster } from "@/components/common/LargePoster"; import { Text } from "@/components/common/Text"; import { DownloadItem } from "@/components/DownloadItem"; +import { PlayedStatus } from "@/components/PlayedStatus"; +import { CastAndCrew } from "@/components/series/CastAndCrew"; +import { CurrentSeries } from "@/components/series/CurrentSeries"; import { SimilarItems } from "@/components/SimilarItems"; import { VideoPlayer } from "@/components/VideoPlayer"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -7,17 +11,10 @@ import { getBackdrop, getStreamUrl, getUserItemData } from "@/utils/jellyfin"; import { Ionicons } from "@expo/vector-icons"; import {} from "@jellyfin/sdk/lib/utils/url"; import { useQuery } from "@tanstack/react-query"; -import { Image } from "expo-image"; -import { Stack, useLocalSearchParams, useNavigation } from "expo-router"; +import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import { useEffect } from "react"; -import { - ActivityIndicator, - Dimensions, - SafeAreaView, - ScrollView, - View, -} from "react-native"; +import { ActivityIndicator, ScrollView, View } from "react-native"; const page: React.FC = () => { const local = useLocalSearchParams(); @@ -40,8 +37,6 @@ const page: React.FC = () => { staleTime: Infinity, }); - const screenWidth = Dimensions.get("window").width; - const { data: playbackURL, isLoading: l2 } = useQuery({ queryKey: ["playbackUrl", id], queryFn: async () => { @@ -57,13 +52,6 @@ const page: React.FC = () => { staleTime: Infinity, }); - const { data: url } = useQuery({ - queryKey: ["backdrop", item?.Id], - queryFn: async () => getBackdrop(api, item), - enabled: !!api && !!item?.Id, - staleTime: Infinity, - }); - useEffect(() => { navigation.setOptions({ headerRight: () => { @@ -90,16 +78,9 @@ const page: React.FC = () => { if (!playbackURL) return null; return ( - - {posterUrl && ( - - - - )} - + + + {item.Type === "Episode" ? ( <> @@ -107,6 +88,13 @@ const page: React.FC = () => { {item?.Name} + + {`S${item?.SeasonName?.replace("Season ", "")}:E${( + item.IndexNumber || 0 + ).toString()}`} + {" - "} + {item.ProductionYear} + ) : ( <> @@ -120,15 +108,54 @@ const page: React.FC = () => { )} - + {playbackURL && } + + + {item.Overview} + + + + Video + Audio + Subtitles + + + + {item.MediaStreams?.find((i) => i.Type === "Video")?.DisplayTitle} + + + {item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle} + + + { + item.MediaStreams?.find((i) => i.Type === "Subtitle") + ?.DisplayTitle + } + + + + + + + + + + {item.Type === "Episode" && ( + + + + )} + + + ); }; diff --git a/app/(auth)/series/[id]/page.tsx b/app/(auth)/series/[id]/page.tsx new file mode 100644 index 00000000..86e768f1 --- /dev/null +++ b/app/(auth)/series/[id]/page.tsx @@ -0,0 +1,61 @@ +import { Text } from "@/components/common/Text"; +import MoviePoster from "@/components/MoviePoster"; +import { NextUp } from "@/components/series/NextUp"; +import { SeasonPicker } from "@/components/series/SeasonPicker"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageById, getUserItemData, nextUp } from "@/utils/jellyfin"; +import { useQuery } from "@tanstack/react-query"; +import { useLocalSearchParams } from "expo-router"; +import { useAtom } from "jotai"; +import { useEffect } from "react"; +import { ScrollView, View } from "react-native"; + +const page: React.FC = () => { + const params = useLocalSearchParams(); + const { id: seriesId } = params as { id: string }; + + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const { data: item } = useQuery({ + queryKey: ["item", seriesId], + queryFn: async () => + await getUserItemData({ + api, + userId: user?.Id, + 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, + }); + + if (!item) return null; + + return ( + + + + + {item?.Name} + {item?.Overview} + + + + + + ); +}; + +export default page; diff --git a/app/(auth)/settings.tsx b/app/(auth)/settings.tsx index f0a92c35..9776fa53 100644 --- a/app/(auth)/settings.tsx +++ b/app/(auth)/settings.tsx @@ -4,15 +4,17 @@ import { runningProcesses } from "@/components/DownloadItem"; import { ListItem } from "@/components/ListItem"; import ProgressCircle from "@/components/ProgressCircle"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; +import { readFromLog } from "@/utils/log"; import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useQuery } from "@tanstack/react-query"; import * as FileSystem from "expo-file-system"; import { useRouter } from "expo-router"; import { FFmpegKit } from "ffmpeg-kit-react-native"; import { useAtom } from "jotai"; import { useEffect, useState } from "react"; -import { TouchableOpacity, View } from "react-native"; +import { ScrollView, TouchableOpacity, View } from "react-native"; const deleteAllFiles = async () => { const directoryUri = FileSystem.documentDirectory; @@ -83,90 +85,118 @@ export default function settings() { })(); }, [key]); + const { data: logs } = useQuery({ + queryKey: ["logs"], + queryFn: async () => readFromLog(), + }); + return ( - - Information + + + Information - - - - - + + + + - - Downloads + - {files.length > 0 ? ( - - {files.map((file) => ( - { - router.back(); - router.push( - `/(auth)/player/offline/page?url=${file.Id}.mp4&itemId=${file.Id}` - ); - }} - > - { - deleteFile(file.Id); - setKey((prevKey) => prevKey + 1); - }} - > - - - } + + Downloads + + {files.length > 0 ? ( + + {files.map((file) => ( + { + router.back(); + router.push( + `/(auth)/player/offline/page?url=${file.Id}.mp4&itemId=${file.Id}` + ); + }} + > + { + deleteFile(file.Id); + setKey((prevKey) => prevKey + 1); + }} + > + + + } + /> + + ))} + + ) : activeProcess ? ( + - - ))} - - ) : activeProcess ? ( - - } - /> - ) : ( - No downloaded files - )} - + } + /> + ) : ( + No downloaded files + )} + - - - {session?.item.Id && ( - )} - + + {session?.item.Id && ( + + )} + + Logs + + {logs?.map((l) => ( + + + {l.level} + + {l.message} + + ))} + + + ); } diff --git a/app/(public)/login.tsx b/app/(public)/login.tsx deleted file mode 100644 index e0bc4d83..00000000 --- a/app/(public)/login.tsx +++ /dev/null @@ -1,145 +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 { 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"), - password: z.string().min(1, "Password is required"), -}); - -const Login: React.FC = () => { - const { setServer, login, removeServer } = useJellyfin(); - const [api] = useAtom(apiAtom); - - const [serverURL, setServerURL] = useState(""); - const [credentials, setCredentials] = useState<{ - username: string; - password: string; - }>({ - username: "", - password: "", - }); - - const [loading, setLoading] = useState(false); - - const handleLogin = async () => { - setLoading(true); - const result = CredentialsSchema.safeParse(credentials); - if (result.success) { - await login(credentials.username, credentials.password); - } - 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 ( - - - Jellyfin - Server: {api.basePath} - - Log in - - setCredentials({ ...credentials, username: text }) - } - value={credentials.username} - autoFocus - secureTextEntry={false} - keyboardType="default" - returnKeyType="done" - autoCapitalize="none" - textContentType="username" - clearButtonMode="while-editing" - maxLength={500} - /> - - setCredentials({ ...credentials, password: text }) - } - value={credentials.password} - secureTextEntry - keyboardType="default" - returnKeyType="done" - autoCapitalize="none" - textContentType="password" - clearButtonMode="while-editing" - maxLength={500} - /> - - - - ); - } - - return ( - - - Jellyfin - Enter a server adress - - - - - ); -}; - -export default Login; diff --git a/app/_layout.tsx b/app/_layout.tsx index 7afb1bcb..c81384f9 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -69,7 +69,6 @@ export default function RootLayout() { name="(auth)/settings" options={{ headerShown: true, - title: "Settings", presentation: "modal", headerLeft: () => ( @@ -106,7 +105,16 @@ export default function RootLayout() { }} /> + diff --git a/bun.lockb b/bun.lockb index 78c521da..cd531206 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/Button.tsx b/components/Button.tsx index 13fd3845..b7df4fd8 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -1,5 +1,6 @@ -import React, { PropsWithChildren, ReactNode } from "react"; +import React, { PropsWithChildren, ReactNode, useMemo } from "react"; import { TouchableOpacity, Text, ActivityIndicator, View } from "react-native"; +import * as Haptics from "expo-haptics"; interface ButtonProps { onPress?: () => void; @@ -8,6 +9,7 @@ interface ButtonProps { disabled?: boolean; children?: string; loading?: boolean; + color?: "purple" | "red"; iconRight?: ReactNode; } @@ -17,19 +19,32 @@ export const Button: React.FC> = ({ textClassName = "", disabled = false, loading = false, + color = "purple", iconRight, children, }) => { + const colorClasses = useMemo(() => { + switch (color) { + case "purple": + return "bg-purple-600 active:bg-purple-700"; + case "red": + return "bg-red-500 active:bg-red-600"; + } + }, [color]); + return ( { - if (!loading && !disabled && onPress) onPress(); + if (!loading && !disabled && onPress) { + onPress(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } }} disabled={disabled || loading} > diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx index cc677952..4fff599f 100644 --- a/components/ContinueWatchingPoster.tsx +++ b/components/ContinueWatchingPoster.tsx @@ -1,11 +1,14 @@ import { apiAtom } from "@/providers/JellyfinProvider"; import { getBackdrop } from "@/utils/jellyfin"; +import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useAtom } from "jotai"; import { useState } from "react"; import { View } from "react-native"; +import { Text } from "./common/Text"; +import { WatchedIndicator } from "./WatchedIndicator"; type ContinueWatchingPosterProps = { item: BaseItemDto; @@ -27,10 +30,13 @@ const ContinueWatchingPoster: React.FC = ({ item.UserData?.PlayedPercentage || 0 ); - if (!url) return ; + if (!url) + return ( + + ); return ( - + = ({ contentFit="cover" className="w-full h-full" /> + {progress > 0 && ( <> )} diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 64218358..1e82feab 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -1,14 +1,15 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import * as FileSystem from "expo-file-system"; -import { atom, useAtom } from "jotai"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { writeToLog } from "@/utils/log"; import Ionicons from "@expo/vector-icons/Ionicons"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { FFmpegKit, FFmpegKitConfig, Session } from "ffmpeg-kit-react-native"; -import ProgressCircle from "./ProgressCircle"; +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 { Text } from "./common/Text"; +import ProgressCircle from "./ProgressCircle"; +import { router } from "expo-router"; type DownloadProps = { item: BaseItemDto; @@ -23,7 +24,13 @@ type ProcessItem = { export const runningProcesses = atom(null); const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => { - if (!item.Id || !item.Name) throw new Error("Item must have an Id and Name"); + 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); @@ -32,8 +39,26 @@ const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => { const command = `-y -fflags +genpts -i ${inputUrl} -c copy -max_muxing_queue_size 9999 ${output}`; const startRemuxing = useCallback(async () => { - if (!item.Id || !item.Name) + 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({ @@ -55,14 +80,6 @@ const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => { percentage = Math.floor((processedFrames / totalFrames) * 100); } - console.log({ - videoLength, - fps, - totalFrames, - processedFrames: statistics.getVideoFrameNumber(), - percentage, - }); - setSession((prev) => { return prev?.item.Id === item.Id! ? { ...prev, progress: percentage } @@ -84,18 +101,49 @@ const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => { JSON.stringify([...otherItems, item]) ); - console.log("Remuxing completed successfully"); + 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]); @@ -142,6 +190,7 @@ export const DownloadItem: React.FC = ({ url, item }) => { onPress={() => { cancelRemuxing(); }} + className="-rotate-45" > = ({ url, item }) => { /> ) : downloaded ? ( - <> - - + { + router.push( + `/(auth)/player/offline/page?url=${item.Id}.mp4&itemId=${item.Id}` + ); + }} + > + + ) : ( { startRemuxing(); }} > - + )} diff --git a/components/ItemCardText.tsx b/components/ItemCardText.tsx index d9333761..f089ab72 100644 --- a/components/ItemCardText.tsx +++ b/components/ItemCardText.tsx @@ -1,9 +1,10 @@ import React from "react"; import { View } from "react-native"; import { Text } from "./common/Text"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; type ItemCardProps = { - item: any; + item: BaseItemDto; }; export const ItemCardText: React.FC = ({ item }) => { @@ -16,17 +17,17 @@ export const ItemCardText: React.FC = ({ item }) => { style={{ flexWrap: "wrap" }} className="flex text-xs opacity-50 break-all" > - {`S${item.SeasonName?.replace("Season ", "").padStart( - 2, - "0" - )}:E${item.IndexNumber.toString().padStart(2, "0")}`}{" "} + {`S${item.SeasonName?.replace( + "Season ", + "" + )}:E${item.IndexNumber?.toString()}`}{" "} {item.Name} ) : ( <> {item.Name} - + {item.ProductionYear} )} diff --git a/components/MoviePoster.tsx b/components/MoviePoster.tsx index ba46b5f6..ba2c32b1 100644 --- a/components/MoviePoster.tsx +++ b/components/MoviePoster.tsx @@ -1,22 +1,27 @@ import { apiAtom } from "@/providers/JellyfinProvider"; -import { getBackdrop } from "@/utils/jellyfin"; +import { getBackdrop, getPrimaryImageById } from "@/utils/jellyfin"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { View } from "react-native"; +import { WatchedIndicator } from "./WatchedIndicator"; type MoviePosterProps = { item: BaseItemDto; + showProgress?: boolean; }; -const MoviePoster: React.FC = ({ item }) => { +const MoviePoster: React.FC = ({ + item, + showProgress = false, +}) => { const [api] = useAtom(apiAtom); const { data: url } = useQuery({ queryKey: ["backdrop", item.Id], - queryFn: async () => getBackdrop(api, item), + queryFn: async () => getPrimaryImageById(api, item.Id), enabled: !!api && !!item.Id, staleTime: Infinity, }); @@ -25,10 +30,18 @@ const MoviePoster: React.FC = ({ item }) => { item.UserData?.PlayedPercentage || 0 ); - if (!url) return ; + if (!url) + return ( + + ); return ( - + = ({ item }) => { aspectRatio: "10/15", }} /> - {progress > 0 && } + + {showProgress && progress > 0 && ( + + )} ); }; diff --git a/components/OfflineVideoPlayer.tsx b/components/OfflineVideoPlayer.tsx index 84d0021c..4e2df40e 100644 --- a/components/OfflineVideoPlayer.tsx +++ b/components/OfflineVideoPlayer.tsx @@ -8,10 +8,8 @@ type VideoPlayerProps = { export const OfflineVideoPlayer: React.FC = ({ url }) => { const videoRef = useRef(null); - console.log(url); - const onError = (error: any) => { - console.log("Video Error: ", error); + console.error("Video Error: ", error); }; useEffect(() => { diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx new file mode 100644 index 00000000..e64d03c0 --- /dev/null +++ b/components/PlayedStatus.tsx @@ -0,0 +1,58 @@ +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"; +import { useAtom } from "jotai"; +import React from "react"; +import { TouchableOpacity, View } from "react-native"; +import * as Haptics from "expo-haptics"; + +export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + console.log("PlayedStatus", item.UserData); + + const queryClient = useQueryClient(); + + return ( + + {item.UserData?.Played ? ( + { + markAsNotPlayed({ + api: api, + itemId: item?.Id, + userId: user?.Id, + }); + queryClient.invalidateQueries({ + queryKey: ["item", item.Id], + refetchType: "all", + }); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }} + > + + + ) : ( + { + markAsPlayed({ + api: api, + itemId: item?.Id, + userId: user?.Id, + }); + queryClient.invalidateQueries({ + queryKey: ["item", item.Id], + refetchType: "all", + }); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }} + > + + + )} + + ); +}; diff --git a/components/Poster.tsx b/components/Poster.tsx new file mode 100644 index 00000000..456b0653 --- /dev/null +++ b/components/Poster.tsx @@ -0,0 +1,51 @@ +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageById } from "@/utils/jellyfin"; +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import { View } from "react-native"; + +type PosterProps = { + itemId?: string | null; + showProgress?: boolean; +}; + +const Poster: React.FC = ({ itemId }) => { + const [api] = useAtom(apiAtom); + + const { data: url } = useQuery({ + queryKey: ["backdrop", itemId], + queryFn: async () => getPrimaryImageById(api, itemId), + enabled: !!api && !!itemId, + staleTime: Infinity, + }); + + if (!url || !itemId) + return ( + + ); + + return ( + + + + ); +}; + +export default Poster; diff --git a/components/ProgressCircle.tsx b/components/ProgressCircle.tsx index 21217b0c..20c4fbd3 100644 --- a/components/ProgressCircle.tsx +++ b/components/ProgressCircle.tsx @@ -24,6 +24,7 @@ const ProgressCircle: React.FC = ({ fill={fill} tintColor={tintColor} backgroundColor={backgroundColor} + rotation={45} /> ); }; diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx index b9cbc3ef..1ce1f5d2 100644 --- a/components/SimilarItems.tsx +++ b/components/SimilarItems.tsx @@ -61,7 +61,9 @@ export const SimilarItems: React.FC = ({ itemId }) => { )} - {similarItems?.length === 0 && No similar items} + {similarItems?.length === 0 && ( + No similar items + )} ); }; diff --git a/components/VerticalPoster.tsx b/components/VerticalPoster.tsx deleted file mode 100644 index 6e059ad0..00000000 --- a/components/VerticalPoster.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getBackdrop } from "@/utils/jellyfin"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useQuery } from "@tanstack/react-query"; -import { Image } from "expo-image"; -import { useAtom } from "jotai"; -import { View } from "react-native"; - -type VerticalPosterProps = { - item: BaseItemDto; -}; - -const VerticalPoster: React.FC = ({ item }) => { - const [api] = useAtom(apiAtom); - - const { data: url } = useQuery({ - queryKey: ["backdrop", item.Id], - queryFn: async () => getBackdrop(api, item), - enabled: !!api && !!item.Id, - staleTime: Infinity, - }); - - if (!url) return null; - - return ( - - - - ); -}; - -export default VerticalPoster; diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx index 4384fcf0..6bc64533 100644 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -1,6 +1,11 @@ -import React, { useEffect, useRef, useState } from "react"; -import { ActivityIndicator, TouchableOpacity, View } from "react-native"; - +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + Switch, + TouchableOpacity, + View, +} from "react-native"; +import * as DropdownMenu from "zeego/dropdown-menu"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { useAtom } from "jotai"; @@ -15,20 +20,52 @@ import { getStreamUrl, getUserItemData, reportPlaybackProgress, + reportPlaybackStopped, } from "@/utils/jellyfin"; import { Ionicons } from "@expo/vector-icons"; import { Button } from "./Button"; import { runtimeTicksToMinutes } from "@/utils/time"; +import { Text } from "./common/Text"; type VideoPlayerProps = { itemId: string; }; +const BITRATES = [ + { + key: "Max", + value: 140000000, + }, + { + key: "10 Mb/s", + value: 10000000, + }, + { + key: "4 Mb/s", + value: 4000000, + }, + { + key: "2 Mb/s", + value: 2000000, + }, + { + key: "1 Mb/s", + value: 1000000, + }, + { + key: "500 Kb/s", + value: 500000, + }, +]; + 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 [paused, setPaused] = useState(true); + const [forceTranscoding, setForceTranscoding] = useState(false); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -61,40 +98,36 @@ export const VideoPlayer: React.FC = ({ itemId }) => { staleTime: Infinity, }); - useEffect(() => { - console.log(item?.UserData?.PlaybackPositionTicks); - console.log(item?.UserData?.PlayedPercentage); - }, [item]); - const { data: playbackURL } = useQuery({ - queryKey: ["playbackUrl", itemId], + queryKey: ["playbackUrl", itemId, maxBitrate, forceTranscoding], queryFn: async () => { - if (!api || !user?.Id) return; - return ( - (await getStreamUrl({ - api, - userId: user.Id, - item, - startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0, - })) || undefined - ); + if (!api || !user?.Id) return null; + + const url = await getStreamUrl({ + api, + userId: user.Id, + item, + startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0, + maxStreamingBitrate: maxBitrate, + forceTranscoding: forceTranscoding, + }); + + console.log("Transcode URL:", url); + + return url; }, enabled: !!itemId && !!api && !!user?.Id && !!item, - staleTime: Infinity, + staleTime: 0, }); - const { data: posterUrl } = useQuery({ - queryKey: ["backdrop", item?.Id], - queryFn: async () => getBackdrop(api, item), - enabled: !!api && !!item?.Id, - staleTime: Infinity, - }); + const [progress, setProgress] = useState(0); const onProgress = ({ currentTime, playableDuration, seekableDuration, }: OnProgressData) => { + setProgress(currentTime * 10000000); reportPlaybackProgress({ api, itemId: itemId, @@ -110,11 +143,11 @@ export const VideoPlayer: React.FC = ({ itemId }) => { currentTime: number; seekTime: number; }) => { - console.log("Seek to time: ", seekTime); + // console.log("Seek to time: ", seekTime); }; const onError = (error: any) => { - console.log("Video Error: ", error); + // console.log("Video Error: ", error); }; const play = () => { @@ -123,45 +156,110 @@ export const VideoPlayer: React.FC = ({ itemId }) => { } }; + const startPosition = useMemo(() => { + return Math.round((item?.UserData?.PlaybackPositionTicks || 0) / 10000); + }, [item]); + useEffect(() => { if (videoRef.current) { videoRef.current.pause(); } }, []); - if (!playbackURL) return null; + const enableVideo = useMemo(() => { + return ( + playbackURL !== undefined && + item !== undefined && + item !== null && + startPosition !== undefined && + sessionData !== undefined + ); + }, [playbackURL, item, startPosition, sessionData]); return ( -