mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-21 16:24:41 +01:00
first commit
This commit is contained in:
55
components/Button.tsx
Normal file
55
components/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
64
components/ContinueWatchingPoster.tsx
Normal file
64
components/ContinueWatchingPoster.tsx
Normal 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
169
components/DownloadItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
34
components/ItemCardText.tsx
Normal file
34
components/ItemCardText.tsx
Normal 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
27
components/ListItem.tsx
Normal 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
9
components/Loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
|
||||
export const Loading: React.FC = () => {
|
||||
return (
|
||||
<View>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
49
components/MoviePoster.tsx
Normal file
49
components/MoviePoster.tsx
Normal 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;
|
||||
45
components/OfflineVideoPlayer.tsx
Normal file
45
components/OfflineVideoPlayer.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
31
components/ProgressCircle.tsx
Normal file
31
components/ProgressCircle.tsx
Normal 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;
|
||||
67
components/SimilarItems.tsx
Normal file
67
components/SimilarItems.tsx
Normal 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
54
components/ThemedText.tsx
Normal 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
15
components/ThemedView.tsx
Normal 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} />;
|
||||
}
|
||||
41
components/VerticalPoster.tsx
Normal file
41
components/VerticalPoster.tsx
Normal 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
177
components/VideoPlayer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
10
components/__tests__/ThemedText-test.tsx
Normal file
10
components/__tests__/ThemedText-test.tsx
Normal 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();
|
||||
});
|
||||
24
components/__tests__/__snapshots__/ThemedText-test.tsx.snap
Normal file
24
components/__tests__/__snapshots__/ThemedText-test.tsx.snap
Normal 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>
|
||||
`;
|
||||
15
components/common/Input.tsx
Normal file
15
components/common/Input.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
components/common/Text.tsx
Normal file
14
components/common/Text.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
9
components/navigation/TabBarIcon.tsx
Normal file
9
components/navigation/TabBarIcon.tsx
Normal 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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user