mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 11:08:26 +01:00
fix
This commit is contained in:
@@ -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<PropsWithChildren<ButtonProps>> = ({
|
||||
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 (
|
||||
<TouchableOpacity
|
||||
className={`
|
||||
bg-purple-600 p-3 rounded-xl items-center justify-center
|
||||
${disabled ? "bg-neutral-400" : "active:bg-purple-600"}
|
||||
${loading && "opacity-50"}
|
||||
p-3 rounded-xl items-center justify-center
|
||||
${loading || (disabled && "opacity-50")}
|
||||
${colorClasses}
|
||||
${className}
|
||||
`}
|
||||
onPress={() => {
|
||||
if (!loading && !disabled && onPress) onPress();
|
||||
if (!loading && !disabled && onPress) {
|
||||
onPress();
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
|
||||
@@ -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<ContinueWatchingPosterProps> = ({
|
||||
item.UserData?.PlayedPercentage || 0
|
||||
);
|
||||
|
||||
if (!url) return <View></View>;
|
||||
if (!url)
|
||||
return (
|
||||
<View className="w-48 aspect-video border border-neutral-800"></View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="w-48 aspect-video rounded relative overflow-hidden border border-neutral-800">
|
||||
<View className="w-48 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
@@ -41,19 +47,20 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
contentFit="cover"
|
||||
className="w-full h-full"
|
||||
/>
|
||||
<WatchedIndicator item={item} />
|
||||
{progress > 0 && (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
width: `100%`,
|
||||
}}
|
||||
className={`absolute bottom-0 left-0 h-1.5 bg-neutral-700 opacity-80 w-full`}
|
||||
className={`absolute bottom-0 left-0 h-1 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`}
|
||||
className={`absolute bottom-0 left-0 h-1 bg-red-600 w-full`}
|
||||
></View>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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<ProcessItem | null>(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<ProcessItem | null>(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<DownloadProps> = ({ url, item }) => {
|
||||
onPress={() => {
|
||||
cancelRemuxing();
|
||||
}}
|
||||
className="-rotate-45"
|
||||
>
|
||||
<ProgressCircle
|
||||
size={22}
|
||||
@@ -152,16 +201,22 @@ export const DownloadItem: React.FC<DownloadProps> = ({ url, item }) => {
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : downloaded ? (
|
||||
<>
|
||||
<Ionicons name="checkmark-circle-outline" size={24} color="white" />
|
||||
</>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(
|
||||
`/(auth)/player/offline/page?url=${item.Id}.mp4&itemId=${item.Id}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="cloud-download" size={28} color="#16a34a" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
startRemuxing();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
<Ionicons name="cloud-download-outline" size={28} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -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<ItemCardProps> = ({ item }) => {
|
||||
@@ -16,17 +17,17 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ 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}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>{item.Name}</Text>
|
||||
<Text></Text>
|
||||
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -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<MoviePosterProps> = ({ item }) => {
|
||||
const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
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<MoviePosterProps> = ({ item }) => {
|
||||
item.UserData?.PlayedPercentage || 0
|
||||
);
|
||||
|
||||
if (!url) return <View></View>;
|
||||
if (!url)
|
||||
return (
|
||||
<View
|
||||
className="rounded-md overflow-hidden border border-neutral-900"
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
></View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="rounded-md overflow-hidden">
|
||||
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
@@ -41,7 +54,10 @@ const MoviePoster: React.FC<MoviePosterProps> = ({ item }) => {
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
/>
|
||||
{progress > 0 && <View className="h-1.5 bg-red-600 w-full"></View>}
|
||||
<WatchedIndicator item={item} />
|
||||
{showProgress && progress > 0 && (
|
||||
<View className="h-1 bg-red-600 w-full"></View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,10 +8,8 @@ type VideoPlayerProps = {
|
||||
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);
|
||||
console.error("Video Error: ", error);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
58
components/PlayedStatus.tsx
Normal file
58
components/PlayedStatus.tsx
Normal file
@@ -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 (
|
||||
<View>
|
||||
{item.UserData?.Played ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
markAsNotPlayed({
|
||||
api: api,
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item", item.Id],
|
||||
refetchType: "all",
|
||||
});
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="checkmark-circle" size={30} color="white" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
markAsPlayed({
|
||||
api: api,
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item", item.Id],
|
||||
refetchType: "all",
|
||||
});
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="checkmark-circle-outline" size={30} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
51
components/Poster.tsx
Normal file
51
components/Poster.tsx
Normal file
@@ -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<PosterProps> = ({ 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 (
|
||||
<View
|
||||
className="border border-neutral-900"
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
></View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="rounded-md overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
key={itemId}
|
||||
id={itemId}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Poster;
|
||||
@@ -24,6 +24,7 @@ const ProgressCircle: React.FC<ProgressCircleProps> = ({
|
||||
fill={fill}
|
||||
tintColor={tintColor}
|
||||
backgroundColor={backgroundColor}
|
||||
rotation={45}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,7 +61,9 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
{similarItems?.length === 0 && <Text>No similar items</Text>}
|
||||
{similarItems?.length === 0 && (
|
||||
<Text className="px-4">No similar items</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<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;
|
||||
@@ -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<VideoPlayerProps> = ({ itemId }) => {
|
||||
const videoRef = useRef<VideoRef | null>(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<boolean>(false);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -61,40 +98,36 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ 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<VideoPlayerProps> = ({ 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<VideoPlayerProps> = ({ 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 (
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
{enableVideo && (
|
||||
<Video
|
||||
style={{ width: 0, height: 0 }}
|
||||
source={{
|
||||
uri: playbackURL!,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
}}
|
||||
ref={videoRef}
|
||||
onSeek={(t) => onSeek(t)}
|
||||
onError={onError}
|
||||
onProgress={(e) => onProgress(e)}
|
||||
onFullscreenPlayerDidDismiss={() => {
|
||||
videoRef.current?.pause();
|
||||
reportPlaybackStopped({
|
||||
api,
|
||||
itemId: item?.Id,
|
||||
positionTicks: progress,
|
||||
sessionId: sessionData?.PlaySessionId,
|
||||
});
|
||||
}}
|
||||
onFullscreenPlayerDidPresent={() => {
|
||||
play();
|
||||
}}
|
||||
paused={paused}
|
||||
onPlaybackStateChanged={(e: OnPlaybackStateChangedData) => {}}
|
||||
bufferConfig={{
|
||||
maxBufferMs: Infinity,
|
||||
minBufferMs: 1000 * 60 * 2,
|
||||
bufferForPlaybackMs: 1000,
|
||||
backBufferDurationMs: 30 * 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 text-xs mb-1">Force transcoding</Text>
|
||||
<Switch
|
||||
value={forceTranscoding}
|
||||
onValueChange={setForceTranscoding}
|
||||
/>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{BITRATES.find((b) => b.value === maxBitrate)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
||||
{BITRATES?.map((b: any) => (
|
||||
<DropdownMenu.Item
|
||||
key={b.value}
|
||||
onSelect={() => {
|
||||
setMaxbitrate(b.value);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col w-full">
|
||||
<Button
|
||||
disabled={!enableVideo}
|
||||
onPress={() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.presentFullscreenPlayer();
|
||||
|
||||
13
components/WatchedIndicator.tsx
Normal file
13
components/WatchedIndicator.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { View } from "react-native";
|
||||
|
||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
return (
|
||||
<>
|
||||
{item.UserData?.Played === false &&
|
||||
(item.Type === "Movie" || item.Type === "Episode") && (
|
||||
<View className="bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45"></View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
47
components/common/HorrizontalScroll.tsx
Normal file
47
components/common/HorrizontalScroll.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { ScrollView, View, ViewStyle, ActivityIndicator } from "react-native";
|
||||
|
||||
interface HorizontalScrollProps<T> {
|
||||
data?: T[] | null;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
containerStyle?: ViewStyle;
|
||||
contentContainerStyle?: ViewStyle;
|
||||
loadingContainerStyle?: ViewStyle;
|
||||
}
|
||||
|
||||
export function HorizontalScroll<T>({
|
||||
data,
|
||||
renderItem,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
loadingContainerStyle,
|
||||
}: HorizontalScrollProps<T>): React.ReactElement {
|
||||
if (!data) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{ flex: 1, justifyContent: "center", alignItems: "center" },
|
||||
loadingContainerStyle,
|
||||
]}
|
||||
>
|
||||
<ActivityIndicator size="small" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
style={containerStyle}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
>
|
||||
<View className="flex flex-row px-4">
|
||||
{data.map((item, index) => (
|
||||
<View className="mr-2" key={index}>
|
||||
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
20
components/common/LargePoster.tsx
Normal file
20
components/common/LargePoster.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Image } from "expo-image";
|
||||
import { View } from "react-native";
|
||||
|
||||
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
|
||||
if (!url)
|
||||
return (
|
||||
<View className="p-4 rounded-xl overflow-hidden ">
|
||||
<View className="w-full aspect-video rounded-xl overflow-hidden border border-neutral-800"></View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="p-4 rounded-xl overflow-hidden ">
|
||||
<Image
|
||||
source={{ uri: url }}
|
||||
className="w-full aspect-video rounded-xl overflow-hidden border border-neutral-800"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
24
components/series/CastAndCrew.tsx
Normal file
24
components/series/CastAndCrew.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
import Poster from "../Poster";
|
||||
|
||||
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Cast & Crew</Text>
|
||||
<HorizontalScroll<NonNullable<BaseItemDto["People"]>[number]>
|
||||
data={item.People}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity key={item.Id} className="flex flex-col w-32">
|
||||
<Poster itemId={item.Id} />
|
||||
<Text className="mt-2">{item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{item.Role}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
28
components/series/CurrentSeries.tsx
Normal file
28
components/series/CurrentSeries.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router } from "expo-router";
|
||||
import React from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import Poster from "../Poster";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Series</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={[item]}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/series/${item.SeriesId}/page`)}
|
||||
className="flex flex-col space-y-2 w-32"
|
||||
>
|
||||
<Poster itemId={item.ParentBackdropItemId} />
|
||||
<Text>{item.SeriesName}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
40
components/series/NextUp.tsx
Normal file
40
components/series/NextUp.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
import Poster from "../Poster";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { router } from "expo-router";
|
||||
|
||||
export const NextUp = ({ items }: { items?: BaseItemDto[] | null }) => {
|
||||
if (!items?.length)
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Next up</Text>
|
||||
<Text className="opacity-50">No items to display</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Next up</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={items}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/items/${item.Id}/page`);
|
||||
}}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-32"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
137
components/series/SeasonPicker.tsx
Normal file
137
components/series/SeasonPicker.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Text } from "../common/Text";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { router } from "expo-router";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
};
|
||||
|
||||
export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
||||
const [selectedSeasonId, setSelectedSeasonId] = useState<string | null>(null);
|
||||
|
||||
const { data: seasons } = useQuery({
|
||||
queryKey: ["seasons", item.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item.Id) return [];
|
||||
const response = await api.axiosInstance.get(
|
||||
`${api.basePath}/Shows/${item.Id}/Seasons`,
|
||||
{
|
||||
params: {
|
||||
userId: user?.Id,
|
||||
itemId: item.Id,
|
||||
Fields:
|
||||
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount",
|
||||
},
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.Items;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!item.Id,
|
||||
});
|
||||
|
||||
const { data: episodes } = useQuery({
|
||||
queryKey: ["episodes", item.Id, selectedSeasonId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item.Id) return [];
|
||||
const response = await api.axiosInstance.get(
|
||||
`${api.basePath}/Shows/${item.Id}/Episodes`,
|
||||
{
|
||||
params: {
|
||||
userId: user?.Id,
|
||||
seasonId: selectedSeasonId,
|
||||
Fields:
|
||||
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
|
||||
},
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.Items as BaseItemDto[];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!seasons || seasons.length === 0) return;
|
||||
|
||||
setSelectedSeasonId(
|
||||
seasons.find((season: any) => season.IndexNumber === 1)?.Id
|
||||
);
|
||||
setSelectedSeason(1);
|
||||
}, [seasons]);
|
||||
|
||||
return (
|
||||
<View className="mb-2">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>Season {selectedSeason}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
|
||||
{seasons?.map((season: any) => (
|
||||
<DropdownMenu.Item
|
||||
key={season.Name}
|
||||
onSelect={() => {
|
||||
setSelectedSeason(season.IndexNumber);
|
||||
setSelectedSeasonId(season.Id);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{episodes && (
|
||||
<View className="mt-2">
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={episodes}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/items/${item.Id}/page`);
|
||||
}}
|
||||
className="flex flex-col w-48"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user