first commit

This commit is contained in:
Fredrik Burmester
2024-07-31 23:19:47 +02:00
commit 98880e05ec
119 changed files with 7305 additions and 0 deletions

55
components/Button.tsx Normal file
View File

@@ -0,0 +1,55 @@
import React, { PropsWithChildren, ReactNode } from "react";
import { TouchableOpacity, Text, ActivityIndicator, View } from "react-native";
interface ButtonProps {
onPress?: () => void;
className?: string;
textClassName?: string;
disabled?: boolean;
children?: string;
loading?: boolean;
iconRight?: ReactNode;
}
export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
onPress,
className = "",
textClassName = "",
disabled = false,
loading = false,
iconRight,
children,
}) => {
return (
<TouchableOpacity
className={`
bg-purple-600 p-3 rounded-xl items-center justify-center
${disabled ? "bg-neutral-400" : "active:bg-purple-600"}
${loading && "opacity-50"}
${className}
`}
onPress={() => {
if (!loading && !disabled && onPress) onPress();
}}
disabled={disabled || loading}
>
{loading ? (
<ActivityIndicator color={"white"} size={24} />
) : (
<View className="flex flex-row items-center">
<Text
className={`
text-white font-bold text-base
${disabled ? "text-gray-300" : ""}
${textClassName}
${iconRight ? "mr-2" : ""}
`}
>
{children}
</Text>
{iconRight}
</View>
)}
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,64 @@
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 { useState } from "react";
import { View } from "react-native";
type ContinueWatchingPosterProps = {
item: BaseItemDto;
};
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item,
}) => {
const [api] = useAtom(apiAtom);
const { data: url } = useQuery({
queryKey: ["backdrop", item.Id],
queryFn: async () => getBackdrop(api, item),
enabled: !!api && !!item.Id,
staleTime: Infinity,
});
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
);
if (!url) return <View></View>;
return (
<View className="w-48 aspect-video rounded relative overflow-hidden border border-neutral-800">
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
/>
{progress > 0 && (
<>
<View
style={{
width: `100%`,
}}
className={`absolute bottom-0 left-0 h-1.5 bg-neutral-700 opacity-80 w-full`}
></View>
<View
style={{
width: `${progress}%`,
}}
className={`absolute bottom-0 left-0 h-1.5 bg-red-600 w-full`}
></View>
</>
)}
</View>
);
};
export default ContinueWatchingPoster;

169
components/DownloadItem.tsx Normal file
View File

@@ -0,0 +1,169 @@
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 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 { TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
type DownloadProps = {
item: BaseItemDto;
url: string;
};
type ProcessItem = {
item: BaseItemDto;
progress: number;
};
export const runningProcesses = atom<ProcessItem | null>(null);
const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => {
if (!item.Id || !item.Name) throw new Error("Item must have an Id and Name");
const [session, setSession] = useAtom<ProcessItem | null>(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)
throw new Error("Item must have an Id and Name");
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);
}
console.log({
videoLength,
fps,
totalFrames,
processedFrames: statistics.getVideoFrameNumber(),
percentage,
});
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])
);
console.log("Remuxing completed successfully");
setSession(null);
} else if (returnCode.isValueError()) {
console.error("Failed to remux:");
setSession(null);
} else if (returnCode.isValueCancel()) {
console.log("Remuxing was cancelled");
setSession(null);
}
});
} catch (error) {
console.error("Failed to remux:", error);
}
}, [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<DownloadProps> = ({ url, item }) => {
const { session, startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(
url,
item
);
const [downloaded, setDownloaded] = useState<boolean>(false);
const [key, setKey] = useState<string>("");
useEffect(() => {
(async () => {
const data: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]"
);
if (data.find((d) => d.Id === item.Id)) setDownloaded(true);
})();
}, [key]);
if (session && session.item.Id !== item.Id!) {
return (
<TouchableOpacity onPress={() => {}} style={{ opacity: 0.5 }}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
</TouchableOpacity>
);
}
return (
<View>
{session ? (
<TouchableOpacity
onPress={() => {
cancelRemuxing();
}}
>
<ProgressCircle
size={22}
fill={session.progress}
width={3}
tintColor="#3498db"
backgroundColor="#bdc3c7"
/>
</TouchableOpacity>
) : downloaded ? (
<>
<Ionicons name="checkmark-circle-outline" size={24} color="white" />
</>
) : (
<TouchableOpacity
onPress={() => {
startRemuxing();
}}
>
<Ionicons name="cloud-download-outline" size={24} color="white" />
</TouchableOpacity>
)}
</View>
);
};

View File

@@ -0,0 +1,34 @@
import React from "react";
import { View } from "react-native";
import { Text } from "./common/Text";
type ItemCardProps = {
item: any;
};
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
return (
<View className="mt-2 flex flex-col grow-0">
{item.Type === "Episode" ? (
<>
<Text className="">{item.SeriesName}</Text>
<Text
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")}`}{" "}
{item.Name}
</Text>
</>
) : (
<>
<Text>{item.Name}</Text>
<Text></Text>
</>
)}
</View>
);
};

27
components/ListItem.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { PropsWithChildren, ReactNode } from "react";
import { View } from "react-native";
import { Text } from "./common/Text";
type Props = {
title?: string | null | undefined;
subTitle?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
};
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
title,
subTitle,
iconAfter,
children,
}) => {
return (
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
<Text className="font-bold ">{title}</Text>
{subTitle && <Text className="text-xs">{subTitle}</Text>}
</View>
{iconAfter}
</View>
);
};

9
components/Loading.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { ActivityIndicator, View } from "react-native";
export const Loading: React.FC = () => {
return (
<View>
<ActivityIndicator />
</View>
);
};

View File

@@ -0,0 +1,49 @@
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 { useEffect, useState } from "react";
import { View } from "react-native";
type MoviePosterProps = {
item: BaseItemDto;
};
const MoviePoster: React.FC<MoviePosterProps> = ({ item }) => {
const [api] = useAtom(apiAtom);
const { data: url } = useQuery({
queryKey: ["backdrop", item.Id],
queryFn: async () => getBackdrop(api, item),
enabled: !!api && !!item.Id,
staleTime: Infinity,
});
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
);
if (!url) return <View></View>;
return (
<View className="rounded-md overflow-hidden">
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
}}
/>
{progress > 0 && <View className="h-1.5 bg-red-600 w-full"></View>}
</View>
);
};
export default MoviePoster;

View File

@@ -0,0 +1,45 @@
import React, { useEffect, useRef } from "react";
import Video, { VideoRef } from "react-native-video";
type VideoPlayerProps = {
url: string;
};
export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
const videoRef = useRef<VideoRef | null>(null);
console.log(url);
const onError = (error: any) => {
console.log("Video Error: ", error);
};
useEffect(() => {
if (videoRef.current) {
videoRef.current.resume();
}
setTimeout(() => {
if (videoRef.current) {
videoRef.current.presentFullscreenPlayer();
}
}, 500);
}, []);
return (
<Video
source={{
uri: url,
isNetwork: false,
}}
controls
ref={videoRef}
onError={onError}
resizeMode="contain"
reportBandwidth
style={{
width: "100%",
aspectRatio: 16 / 9,
}}
/>
);
};

View File

@@ -0,0 +1,31 @@
import React from "react";
import { View, StyleSheet } from "react-native";
import { AnimatedCircularProgress } from "react-native-circular-progress";
type ProgressCircleProps = {
size: number;
fill: number; // Progress percentage (0 to 100)
width: number; // Stroke width of the circle
tintColor: string; // Color of the progress part
backgroundColor: string; // Color of the remaining part
};
const ProgressCircle: React.FC<ProgressCircleProps> = ({
size,
fill,
width,
tintColor,
backgroundColor,
}) => {
return (
<AnimatedCircularProgress
size={size}
width={width}
fill={fill}
tintColor={tintColor}
backgroundColor={backgroundColor}
/>
);
};
export default ProgressCircle;

View File

@@ -0,0 +1,67 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import ContinueWatchingPoster from "./ContinueWatchingPoster";
import { ItemCardText } from "./ItemCardText";
import { Text } from "./common/Text";
type SimilarItemsProps = {
itemId: string;
};
export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({
queryKey: ["similarItems", itemId],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getLibraryApi(api).getSimilarItems({
itemId,
userId: user.Id,
limit: 5,
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id,
staleTime: Infinity,
});
return (
<View>
<Text className="px-4 text-2xl font-bold mb-2">Similar items</Text>
{isLoading ? (
<View className="my-12">
<ActivityIndicator />
</View>
) : (
<ScrollView horizontal>
<View className="px-4 flex flex-row gap-x-2">
{similarItems?.map((item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-48"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
))}
</View>
</ScrollView>
)}
{similarItems?.length === 0 && <Text>No similar items</Text>}
</View>
);
};

54
components/ThemedText.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { Text, type TextProps, StyleSheet } from "react-native";
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link";
};
export function ThemedText({
style,
type = "default",
...rest
}: ThemedTextProps) {
return (
<Text
style={[
{ color: "white" },
type === "default" ? styles.default : undefined,
type === "title" ? styles.title : undefined,
type === "defaultSemiBold" ? styles.defaultSemiBold : undefined,
type === "subtitle" ? styles.subtitle : undefined,
type === "link" ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: "600",
},
title: {
fontSize: 32,
fontWeight: "bold",
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: "bold",
},
link: {
lineHeight: 30,
fontSize: 16,
color: "#0a7ea4",
},
});

15
components/ThemedView.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { View, type ViewProps } from "react-native";
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({
style,
lightColor,
darkColor,
...otherProps
}: ThemedViewProps) {
return <View style={[{ backgroundColor: "black" }, style]} {...otherProps} />;
}

View File

@@ -0,0 +1,41 @@
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<VerticalPosterProps> = ({ 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 (
<View>
<Image
source={{
uri: url,
}}
style={{
height: 180,
width: 130,
borderRadius: 10,
}}
/>
</View>
);
};
export default VerticalPoster;

177
components/VideoPlayer.tsx Normal file
View File

@@ -0,0 +1,177 @@
import React, { useEffect, useRef, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import Video, {
OnPlaybackStateChangedData,
OnProgressData,
VideoRef,
} from "react-native-video";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import {
getBackdrop,
getStreamUrl,
getUserItemData,
reportPlaybackProgress,
} from "@/utils/jellyfin";
import { Ionicons } from "@expo/vector-icons";
import { Button } from "./Button";
import { runtimeTicksToMinutes } from "@/utils/time";
type VideoPlayerProps = {
itemId: string;
};
export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
const videoRef = useRef<VideoRef | null>(null);
const [showPoster, setShowPoster] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [buffering, setBuffering] = useState(false);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const {} = useJellyfin();
const { data: sessionData } = useQuery({
queryKey: ["sessionData", itemId],
queryFn: async () => {
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!itemId && !!api && !!user?.Id,
staleTime: Infinity,
});
const { data: item } = useQuery({
queryKey: ["item", itemId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId,
}),
enabled: !!itemId && !!api,
staleTime: Infinity,
});
useEffect(() => {
console.log(item?.UserData?.PlaybackPositionTicks);
console.log(item?.UserData?.PlayedPercentage);
}, [item]);
const { data: playbackURL } = useQuery({
queryKey: ["playbackUrl", itemId],
queryFn: async () => {
if (!api || !user?.Id) return;
return (
(await getStreamUrl({
api,
userId: user.Id,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
})) || undefined
);
},
enabled: !!itemId && !!api && !!user?.Id && !!item,
staleTime: Infinity,
});
const { data: posterUrl } = useQuery({
queryKey: ["backdrop", item?.Id],
queryFn: async () => getBackdrop(api, item),
enabled: !!api && !!item?.Id,
staleTime: Infinity,
});
const onProgress = ({
currentTime,
playableDuration,
seekableDuration,
}: OnProgressData) => {
reportPlaybackProgress({
api,
itemId: itemId,
positionTicks: currentTime * 10000000,
sessionId: sessionData?.PlaySessionId,
});
};
const onSeek = ({
currentTime,
seekTime,
}: {
currentTime: number;
seekTime: number;
}) => {
console.log("Seek to time: ", seekTime);
};
const onError = (error: any) => {
console.log("Video Error: ", error);
};
const play = () => {
if (videoRef.current) {
videoRef.current.resume();
}
};
useEffect(() => {
if (videoRef.current) {
videoRef.current.pause();
}
}, []);
if (!playbackURL) return null;
return (
<View>
<Video
style={{ width: 0, height: 0 }}
source={{
uri: playbackURL,
isNetwork: true,
startPosition: Math.round(
(item?.UserData?.PlaybackPositionTicks || 0) / 10000
),
}}
ref={videoRef}
onSeek={(t) => onSeek(t)}
onError={onError}
onProgress={(e) => onProgress(e)}
onFullscreenPlayerDidDismiss={() => {
videoRef.current?.pause();
}}
onFullscreenPlayerDidPresent={() => {
play();
}}
onPlaybackStateChanged={(e: OnPlaybackStateChangedData) => {}}
bufferConfig={{
maxBufferMs: Infinity,
minBufferMs: 1000 * 60 * 2,
bufferForPlaybackMs: 1000,
backBufferDurationMs: 30 * 1000,
}}
/>
<View className="flex flex-col w-full">
<Button
onPress={() => {
if (videoRef.current) {
videoRef.current.presentFullscreenPlayer();
}
}}
iconRight={<Ionicons name="play-circle" size={24} color="white" />}
>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Button>
</View>
</View>
);
};

View File

@@ -0,0 +1,10 @@
import * as React from 'react';
import renderer from 'react-test-renderer';
import { ThemedText } from '../ThemedText';
it(`renders correctly`, () => {
const tree = renderer.create(<ThemedText>Snapshot test!</ThemedText>).toJSON();
expect(tree).toMatchSnapshot();
});

View File

@@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<Text
style={
[
{
"color": "#11181C",
},
{
"fontSize": 16,
"lineHeight": 24,
},
undefined,
undefined,
undefined,
undefined,
undefined,
]
}
>
Snapshot test!
</Text>
`;

View File

@@ -0,0 +1,15 @@
import React from "react";
import { TextInputProps, TextProps } from "react-native";
import { TextInput } from "react-native";
export function Input(props: TextInputProps) {
const { style, ...otherProps } = props;
return (
<TextInput
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
/>
);
}

View File

@@ -0,0 +1,14 @@
import React from "react";
import { TextProps } from "react-native";
import { Text as DefaultText } from "react-native";
export function Text(props: TextProps) {
const { style, ...otherProps } = props;
return (
<DefaultText
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
/>
);
}

View File

@@ -0,0 +1,9 @@
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
import Ionicons from '@expo/vector-icons/Ionicons';
import { type IconProps } from '@expo/vector-icons/build/createIconSet';
import { type ComponentProps } from 'react';
export function TabBarIcon({ style, ...rest }: IconProps<ComponentProps<typeof Ionicons>['name']>) {
return <Ionicons size={28} style={[{ marginBottom: -3 }, style]} {...rest} />;
}