mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-14 18:00:32 +01:00
Merge pull request #156 from fredrikburmester/feat/live-tv
feat: live-tv support
This commit is contained in:
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.16.0",
|
"version": "0.17.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export default function index() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
await queryClient.invalidateQueries();
|
await queryClient.invalidateQueries();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [queryClient, user?.Id]);
|
}, []);
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
const createCollectionConfig = useCallback(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -1,15 +1,94 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { ItemContent } from "@/components/ItemContent";
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
import { Stack, useLocalSearchParams } from "expo-router";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import React from "react";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
const { id } = useLocalSearchParams() as { id: string };
|
const { id } = useLocalSearchParams() as { id: string };
|
||||||
|
|
||||||
|
const { data: item, isError } = useQuery({
|
||||||
|
queryKey: ["item", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
enabled: !!id && !!api,
|
||||||
|
staleTime: 60 * 1000 * 5, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: opacity.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const fadeOut = (callback: any) => {
|
||||||
|
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||||
|
if (finished) {
|
||||||
|
runOnJS(callback)();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fadeIn = (callback: any) => {
|
||||||
|
opacity.value = withTiming(1, { duration: 300 }, (finished) => {
|
||||||
|
if (finished) {
|
||||||
|
runOnJS(callback)();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
fadeOut(() => {});
|
||||||
|
} else {
|
||||||
|
fadeIn(() => {});
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
if (isError)
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col items-center justify-center h-screen w-screen">
|
||||||
|
<Text>Could not load item</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<View className="flex flex-1 relative">
|
||||||
<Stack.Screen options={{ autoHideHomeIndicator: true }} />
|
<Animated.View
|
||||||
<ItemContent id={id} />
|
pointerEvents={"none"}
|
||||||
</>
|
style={[animatedStyle]}
|
||||||
|
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
||||||
|
>
|
||||||
|
<View className="h-[350px] bg-transparent rounded-lg mb-4 w-full"></View>
|
||||||
|
<View className="h-6 bg-neutral-900 rounded mb-1 w-12"></View>
|
||||||
|
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-1/2"></View>
|
||||||
|
<View className="h-12 bg-neutral-900 rounded-lg w-2/3 mb-10"></View>
|
||||||
|
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||||
|
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||||
|
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||||
|
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-1/4"></View>
|
||||||
|
</Animated.View>
|
||||||
|
{item && <ItemContent item={item} />}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
49
app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx
Normal file
49
app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type {
|
||||||
|
MaterialTopTabNavigationEventMap,
|
||||||
|
MaterialTopTabNavigationOptions,
|
||||||
|
} from "@react-navigation/material-top-tabs";
|
||||||
|
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
||||||
|
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
|
||||||
|
import { Stack, withLayoutContext } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|
||||||
|
export const Tab = withLayoutContext<
|
||||||
|
MaterialTopTabNavigationOptions,
|
||||||
|
typeof Navigator,
|
||||||
|
TabNavigationState<ParamListBase>,
|
||||||
|
MaterialTopTabNavigationEventMap
|
||||||
|
>(Navigator);
|
||||||
|
|
||||||
|
const Layout = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: "Live TV" }} />
|
||||||
|
<Tab
|
||||||
|
initialRouteName="programs"
|
||||||
|
keyboardDismissMode="none"
|
||||||
|
screenOptions={{
|
||||||
|
tabBarBounces: true,
|
||||||
|
tabBarLabelStyle: { fontSize: 10 },
|
||||||
|
tabBarItemStyle: {
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
tabBarStyle: { backgroundColor: "black" },
|
||||||
|
animationEnabled: true,
|
||||||
|
lazy: true,
|
||||||
|
swipeEnabled: true,
|
||||||
|
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
|
||||||
|
tabBarScrollEnabled: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen name="programs" />
|
||||||
|
<Tab.Screen name="guide" />
|
||||||
|
<Tab.Screen name="channels" />
|
||||||
|
<Tab.Screen name="recordings" />
|
||||||
|
</Tab>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { data: channels } = useQuery({
|
||||||
|
queryKey: ["livetv", "channels"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 500,
|
||||||
|
enableFavoriteSorting: true,
|
||||||
|
userId: user?.Id,
|
||||||
|
addCurrentProgram: false,
|
||||||
|
enableUserData: false,
|
||||||
|
enableImageTypes: ["Primary"],
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-1">
|
||||||
|
<FlashList
|
||||||
|
data={channels?.Items}
|
||||||
|
estimatedItemSize={76}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View className="flex flex-row items-center px-4 mb-2">
|
||||||
|
<View className="w-22 mr-4 rounded-lg overflow-hidden">
|
||||||
|
<ItemImage
|
||||||
|
style={{
|
||||||
|
aspectRatio: "1/1",
|
||||||
|
width: 60,
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
item={item}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text className="font-bold">{item.Name}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx
Normal file
168
app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||||
|
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Button, Dimensions, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
const HOUR_HEIGHT = 30;
|
||||||
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
|
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [date, setDate] = useState<Date>(new Date());
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
const { data: guideInfo } = useQuery({
|
||||||
|
queryKey: ["livetv", "guideInfo"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getLiveTvApi(api!).getGuideInfo();
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: channels } = useQuery({
|
||||||
|
queryKey: ["livetv", "channels", currentPage],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
||||||
|
startIndex: (currentPage - 1) * ITEMS_PER_PAGE,
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
enableFavoriteSorting: true,
|
||||||
|
userId: user?.Id,
|
||||||
|
addCurrentProgram: false,
|
||||||
|
enableUserData: false,
|
||||||
|
enableImageTypes: ["Primary"],
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: programs } = useQuery({
|
||||||
|
queryKey: ["livetv", "programs", date, currentPage],
|
||||||
|
queryFn: async () => {
|
||||||
|
const startOfDay = new Date(date);
|
||||||
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
|
const endOfDay = new Date(date);
|
||||||
|
endOfDay.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = startOfDay.toDateString() === now.toDateString();
|
||||||
|
|
||||||
|
const res = await getLiveTvApi(api!).getPrograms({
|
||||||
|
getProgramsDto: {
|
||||||
|
MaxStartDate: endOfDay.toISOString(),
|
||||||
|
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
|
||||||
|
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
|
||||||
|
Boolean
|
||||||
|
) as string[],
|
||||||
|
ImageTypeLimit: 1,
|
||||||
|
EnableImages: false,
|
||||||
|
SortBy: ["StartDate"],
|
||||||
|
EnableTotalRecordCount: false,
|
||||||
|
EnableUserData: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: !!channels,
|
||||||
|
});
|
||||||
|
|
||||||
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
|
||||||
|
const memoizedChannels = useMemo(() => channels?.Items || [], [channels]);
|
||||||
|
|
||||||
|
const [scrollX, setScrollX] = useState(0);
|
||||||
|
|
||||||
|
const handleNextPage = useCallback(() => {
|
||||||
|
setCurrentPage((prev) => prev + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePrevPage = useCallback(() => {
|
||||||
|
setCurrentPage((prev) => Math.max(1, prev - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
key={"home"}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex flex-row bg-neutral-800 w-full items-end">
|
||||||
|
<Button
|
||||||
|
title="Previous"
|
||||||
|
onPress={handlePrevPage}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Next"
|
||||||
|
onPress={handleNextPage}
|
||||||
|
disabled={
|
||||||
|
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-row">
|
||||||
|
<View className="flex flex-col w-[64px]">
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: HOUR_HEIGHT,
|
||||||
|
}}
|
||||||
|
className="bg-neutral-800"
|
||||||
|
></View>
|
||||||
|
{channels?.Items?.map((c, i) => (
|
||||||
|
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
|
||||||
|
<ItemImage
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
resizeMode: "contain",
|
||||||
|
}}
|
||||||
|
item={c}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
style={{
|
||||||
|
width: screenWidth - 64,
|
||||||
|
}}
|
||||||
|
horizontal
|
||||||
|
scrollEnabled
|
||||||
|
onScroll={(e) => {
|
||||||
|
setScrollX(e.nativeEvent.contentOffset.x);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<HourHeader height={HOUR_HEIGHT} />
|
||||||
|
{channels?.Items?.map((c, i) => (
|
||||||
|
<MemoizedLiveTVGuideRow
|
||||||
|
channel={c}
|
||||||
|
programs={programs?.Items}
|
||||||
|
key={c.Id}
|
||||||
|
scrollX={scrollX}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
app/(auth)/(tabs)/(home,libraries,search)/livetv/programs.tsx
Normal file
150
app/(auth)/(tabs)/(home,libraries,search)/livetv/programs.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
ScrollView,
|
||||||
|
View
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
key={"home"}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
paddingTop: 8,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryKey={["livetv", "recommended"]}
|
||||||
|
title={"On now"}
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return [] as BaseItemDto[];
|
||||||
|
const res = await getLiveTvApi(api).getRecommendedPrograms({
|
||||||
|
userId: user?.Id,
|
||||||
|
isAiring: true,
|
||||||
|
limit: 24,
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
}}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryKey={["livetv", "shows"]}
|
||||||
|
title={"Shows"}
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return [] as BaseItemDto[];
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user?.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 9,
|
||||||
|
isMovie: false,
|
||||||
|
isSeries: true,
|
||||||
|
isSports: false,
|
||||||
|
isNews: false,
|
||||||
|
isKids: false,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
}}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryKey={["livetv", "movies"]}
|
||||||
|
title={"Movies"}
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return [] as BaseItemDto[];
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user?.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 9,
|
||||||
|
isMovie: true,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
}}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryKey={["livetv", "sports"]}
|
||||||
|
title={"Sports"}
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return [] as BaseItemDto[];
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user?.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 9,
|
||||||
|
isSports: true,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
}}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryKey={["livetv", "kids"]}
|
||||||
|
title={"For Kids"}
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return [] as BaseItemDto[];
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user?.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 9,
|
||||||
|
isKids: true,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
}}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryKey={["livetv", "news"]}
|
||||||
|
title={"News"}
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return [] as BaseItemDto[];
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user?.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 9,
|
||||||
|
isNews: true,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
}}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
return (
|
||||||
|
<View className="flex items-center justify-center h-full -mt-12">
|
||||||
|
<Text>Coming soon</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -337,6 +337,7 @@ function Layout() {
|
|||||||
name="(auth)/play"
|
name="(auth)/play"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
title: "",
|
title: "",
|
||||||
animation: "fade",
|
animation: "fade",
|
||||||
}}
|
}}
|
||||||
@@ -345,6 +346,7 @@ function Layout() {
|
|||||||
name="(auth)/play-music"
|
name="(auth)/play-music"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
title: "",
|
title: "",
|
||||||
animation: "fade",
|
animation: "fade",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -40,11 +40,26 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
else
|
else
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}
|
}
|
||||||
|
if (item.Type === "Program") {
|
||||||
|
if (item.ImageTags?.["Thumb"])
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
|
||||||
|
else
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
|
}
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const [progress, setProgress] = useState(
|
const progress = useMemo(() => {
|
||||||
item.UserData?.PlayedPercentage || 0
|
if (item.Type === "Program") {
|
||||||
);
|
const startDate = new Date(item.StartDate || "");
|
||||||
|
const endDate = new Date(item.EndDate || "");
|
||||||
|
const now = new Date();
|
||||||
|
const total = endDate.getTime() - startDate.getTime();
|
||||||
|
const elapsed = now.getTime() - startDate.getTime();
|
||||||
|
return (elapsed / total) * 100;
|
||||||
|
} else {
|
||||||
|
return item.UserData?.PlayedPercentage || 0;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,295 +14,242 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous
|
|||||||
import { useImageColors } from "@/hooks/useImageColors";
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
import ios from "@/utils/profiles/ios";
|
|
||||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||||
import native from "@/utils/profiles/native";
|
import native from "@/utils/profiles/native";
|
||||||
import old from "@/utils/profiles/old";
|
import old from "@/utils/profiles/old";
|
||||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { Stack, useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useCastDevice } from "react-native-google-cast";
|
import { useCastDevice } from "react-native-google-cast";
|
||||||
import Animated, {
|
import Animated from "react-native-reanimated";
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Chromecast } from "./Chromecast";
|
import { Chromecast } from "./Chromecast";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { Loader } from "./Loader";
|
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
|
|
||||||
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||||
const [api] = useAtom(apiAtom);
|
({ item }) => {
|
||||||
const [user] = useAtom(userAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const opacity = useSharedValue(0);
|
const castDevice = useCastDevice();
|
||||||
const castDevice = useCastDevice();
|
const navigation = useNavigation();
|
||||||
const navigation = useNavigation();
|
const [settings] = useSettings();
|
||||||
const [settings] = useSettings();
|
const [selectedMediaSource, setSelectedMediaSource] =
|
||||||
const [selectedMediaSource, setSelectedMediaSource] =
|
useState<MediaSourceInfo | null>(null);
|
||||||
useState<MediaSourceInfo | null>(null);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
useState<number>(-1);
|
||||||
useState<number>(-1);
|
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
key: "Max",
|
||||||
key: "Max",
|
value: undefined,
|
||||||
value: undefined,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
|
|
||||||
const [orientation, setOrientation] = useState(
|
const [orientation, setOrientation] = useState(
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
|
||||||
(event) => {
|
|
||||||
setOrientation(event.orientationInfo.orientation);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
useEffect(() => {
|
||||||
setOrientation(initialOrientation);
|
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||||
});
|
(event) => {
|
||||||
|
setOrientation(event.orientationInfo.orientation);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
setOrientation(initialOrientation);
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
opacity: opacity.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const fadeIn = () => {
|
|
||||||
opacity.value = withTiming(1, { duration: 300 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const fadeOut = (callback: any) => {
|
|
||||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
|
||||||
if (finished) {
|
|
||||||
runOnJS(callback)();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const headerHeightRef = useRef(400);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: item,
|
|
||||||
isLoading,
|
|
||||||
isFetching,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getUserItemData({
|
|
||||||
api,
|
|
||||||
userId: user?.Id,
|
|
||||||
itemId: id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return res;
|
return () => {
|
||||||
},
|
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||||
enabled: !!id && !!api,
|
};
|
||||||
staleTime: 60 * 1000 * 5,
|
}, []);
|
||||||
});
|
|
||||||
|
|
||||||
const [localItem, setLocalItem] = useState(item);
|
const headerHeightRef = useRef(400);
|
||||||
useImageColors(item);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useImageColors({ item });
|
||||||
if (item) {
|
|
||||||
if (localItem) {
|
useEffect(() => {
|
||||||
// Fade out current item
|
navigation.setOptions({
|
||||||
fadeOut(() => {
|
headerRight: () =>
|
||||||
// Update local item after fade out
|
item && (
|
||||||
setLocalItem(item);
|
<View className="flex flex-row items-center space-x-2">
|
||||||
// Then fade in
|
<Chromecast background="blur" width={22} height={22} />
|
||||||
fadeIn();
|
{item.Type !== "Program" && (
|
||||||
|
<>
|
||||||
|
<DownloadItem item={item} />
|
||||||
|
<PlayedStatus item={item} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
|
||||||
|
headerHeightRef.current = 230;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.Type === "Episode") headerHeightRef.current = 400;
|
||||||
|
else if (item.Type === "Movie") headerHeightRef.current = 500;
|
||||||
|
else headerHeightRef.current = 400;
|
||||||
|
}, [item, orientation]);
|
||||||
|
|
||||||
|
const { data: sessionData } = useQuery({
|
||||||
|
queryKey: ["sessionData", item.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !item.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo(
|
||||||
|
{
|
||||||
|
itemId: item.Id,
|
||||||
|
userId: user?.Id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return playbackData.data;
|
||||||
|
},
|
||||||
|
enabled: !!item.Id && !!api && !!user?.Id,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: playbackUrl } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"playbackUrl",
|
||||||
|
item.Id,
|
||||||
|
maxBitrate,
|
||||||
|
castDevice?.deviceId,
|
||||||
|
selectedMediaSource?.Id,
|
||||||
|
selectedAudioStream,
|
||||||
|
selectedSubtitleStream,
|
||||||
|
settings,
|
||||||
|
sessionData?.PlaySessionId,
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
item.Type !== "Program" &&
|
||||||
|
(!sessionData || !selectedMediaSource?.Id)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deviceProfile: any = iosFmp4;
|
||||||
|
|
||||||
|
if (castDevice?.deviceId) {
|
||||||
|
deviceProfile = chromecastProfile;
|
||||||
|
} else if (settings?.deviceProfile === "Native") {
|
||||||
|
deviceProfile = native;
|
||||||
|
} else if (settings?.deviceProfile === "Old") {
|
||||||
|
deviceProfile = old;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("playbackUrl...");
|
||||||
|
|
||||||
|
const url = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
userId: user.Id,
|
||||||
|
item,
|
||||||
|
startTimeTicks: item.UserData?.PlaybackPositionTicks || 0,
|
||||||
|
maxStreamingBitrate: maxBitrate.value,
|
||||||
|
sessionData,
|
||||||
|
deviceProfile,
|
||||||
|
audioStreamIndex: selectedAudioStream,
|
||||||
|
subtitleStreamIndex: selectedSubtitleStream,
|
||||||
|
forceDirectPlay: settings?.forceDirectPlay,
|
||||||
|
height: maxBitrate.height,
|
||||||
|
mediaSourceId: selectedMediaSource?.Id,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// If there's no current item, just set and fade in
|
|
||||||
setLocalItem(item);
|
|
||||||
fadeIn();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If item is null, fade out and clear local item
|
|
||||||
fadeOut(() => setLocalItem(null));
|
|
||||||
}
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
console.info("Stream URL:", url);
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () =>
|
return url;
|
||||||
item && (
|
},
|
||||||
<View className="flex flex-row items-center space-x-2">
|
enabled: !!api && !!user?.Id && !!item.Id,
|
||||||
<Chromecast background="blur" width={22} height={22} />
|
staleTime: 0,
|
||||||
<DownloadItem item={item} />
|
|
||||||
<PlayedStatus item={item} />
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||||
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
|
|
||||||
headerHeightRef.current = 230;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (item?.Type === "Episode") headerHeightRef.current = 400;
|
|
||||||
else if (item?.Type === "Movie") headerHeightRef.current = 500;
|
|
||||||
else headerHeightRef.current = 400;
|
|
||||||
}, [item, orientation]);
|
|
||||||
|
|
||||||
const { data: sessionData } = useQuery({
|
const loading = useMemo(() => {
|
||||||
queryKey: ["sessionData", item?.Id],
|
return Boolean(logoUrl && loadingLogo);
|
||||||
queryFn: async () => {
|
}, [loadingLogo, logoUrl]);
|
||||||
if (!api || !user?.Id || !item?.Id) return null;
|
|
||||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
|
||||||
itemId: item?.Id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return playbackData.data;
|
const insets = useSafeAreaInsets();
|
||||||
},
|
|
||||||
enabled: !!item?.Id && !!api && !!user?.Id,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: playbackUrl } = useQuery({
|
return (
|
||||||
queryKey: [
|
<View
|
||||||
"playbackUrl",
|
className="flex-1 relative"
|
||||||
item?.Id,
|
style={{
|
||||||
maxBitrate,
|
paddingLeft: insets.left,
|
||||||
castDevice,
|
paddingRight: insets.right,
|
||||||
selectedMediaSource,
|
}}
|
||||||
selectedAudioStream,
|
>
|
||||||
selectedSubtitleStream,
|
<ParallaxScrollView
|
||||||
settings,
|
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||||
],
|
headerHeight={headerHeightRef.current}
|
||||||
queryFn: async () => {
|
headerImage={
|
||||||
if (!api || !user?.Id || !sessionData || !selectedMediaSource?.Id)
|
<>
|
||||||
return null;
|
<Animated.View style={[{ flex: 1 }]}>
|
||||||
|
|
||||||
let deviceProfile: any = iosFmp4;
|
|
||||||
|
|
||||||
if (castDevice?.deviceId) {
|
|
||||||
deviceProfile = chromecastProfile;
|
|
||||||
} else if (settings?.deviceProfile === "Native") {
|
|
||||||
deviceProfile = native;
|
|
||||||
} else if (settings?.deviceProfile === "Old") {
|
|
||||||
deviceProfile = old;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
userId: user.Id,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
|
||||||
maxStreamingBitrate: maxBitrate.value,
|
|
||||||
sessionData,
|
|
||||||
deviceProfile,
|
|
||||||
audioStreamIndex: selectedAudioStream,
|
|
||||||
subtitleStreamIndex: selectedSubtitleStream,
|
|
||||||
forceDirectPlay: settings?.forceDirectPlay,
|
|
||||||
height: maxBitrate.height,
|
|
||||||
mediaSourceId: selectedMediaSource.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.info("Stream URL:", url);
|
|
||||||
|
|
||||||
return url;
|
|
||||||
},
|
|
||||||
enabled: !!sessionData && !!api && !!user?.Id && !!item?.Id,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
|
||||||
return Boolean(isLoading || isFetching || (logoUrl && loadingLogo));
|
|
||||||
}, [isLoading, isFetching, loadingLogo, logoUrl]);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className="flex-1 relative"
|
|
||||||
style={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading && (
|
|
||||||
<View className="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex flex-col justify-center items-center z-50">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<ParallaxScrollView
|
|
||||||
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
|
||||||
headerHeight={headerHeightRef.current}
|
|
||||||
headerImage={
|
|
||||||
<>
|
|
||||||
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
|
||||||
{localItem && (
|
|
||||||
<ItemImage
|
<ItemImage
|
||||||
variant={
|
variant={
|
||||||
localItem.Type === "Movie" && logoUrl
|
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
|
||||||
? "Backdrop"
|
|
||||||
: "Primary"
|
|
||||||
}
|
}
|
||||||
item={localItem}
|
item={item}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
</Animated.View>
|
||||||
</Animated.View>
|
</>
|
||||||
</>
|
}
|
||||||
}
|
logo={
|
||||||
logo={
|
<>
|
||||||
<>
|
{logoUrl ? (
|
||||||
{logoUrl ? (
|
<Image
|
||||||
<Image
|
source={{
|
||||||
source={{
|
uri: logoUrl,
|
||||||
uri: logoUrl,
|
}}
|
||||||
}}
|
style={{
|
||||||
style={{
|
height: 130,
|
||||||
height: 130,
|
width: "100%",
|
||||||
width: "100%",
|
resizeMode: "contain",
|
||||||
resizeMode: "contain",
|
}}
|
||||||
}}
|
onLoad={() => setLoadingLogo(false)}
|
||||||
onLoad={() => setLoadingLogo(false)}
|
onError={() => setLoadingLogo(false)}
|
||||||
onError={() => setLoadingLogo(false)}
|
/>
|
||||||
/>
|
) : null}
|
||||||
) : null}
|
</>
|
||||||
</>
|
}
|
||||||
}
|
>
|
||||||
>
|
<View className="flex flex-col bg-transparent shrink">
|
||||||
<View className="flex flex-col bg-transparent shrink">
|
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||||
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
<ItemHeader item={item} className="mb-4" />
|
||||||
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
{item.Type !== "Program" && (
|
||||||
<ItemHeader item={localItem} className="mb-4" />
|
|
||||||
{localItem ? (
|
|
||||||
<View className="flex flex-row items-center justify-start w-full h-16">
|
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
className="mr-1"
|
className="mr-1"
|
||||||
@@ -311,7 +258,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
/>
|
/>
|
||||||
<MediaSourceSelector
|
<MediaSourceSelector
|
||||||
className="mr-1"
|
className="mr-1"
|
||||||
item={localItem}
|
item={item}
|
||||||
onChange={setSelectedMediaSource}
|
onChange={setSelectedMediaSource}
|
||||||
selected={selectedMediaSource}
|
selected={selectedMediaSource}
|
||||||
/>
|
/>
|
||||||
@@ -331,46 +278,45 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : (
|
|
||||||
<View className="h-16">
|
|
||||||
<View className="bg-neutral-900 h-4 w-2/4 rounded-md mb-1"></View>
|
|
||||||
<View className="bg-neutral-900 h-10 w-3/4 rounded-lg"></View>
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<PlayButton item={item} url={playbackUrl} className="grow" />
|
<PlayButton item={item} url={playbackUrl} className="grow" />
|
||||||
</View>
|
|
||||||
|
|
||||||
{item?.Type === "Episode" && (
|
|
||||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<OverviewText text={item?.Overview} className="px-4 my-4" />
|
|
||||||
|
|
||||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
|
||||||
|
|
||||||
{item?.People && item.People.length > 0 && (
|
|
||||||
<View className="mb-4">
|
|
||||||
{item.People.slice(0, 3).map((person) => (
|
|
||||||
<MoreMoviesWithActor
|
|
||||||
currentItem={item}
|
|
||||||
key={person.Id}
|
|
||||||
actorId={person.Id!}
|
|
||||||
className="mb-4"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
|
|
||||||
{item?.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<CurrentSeries item={item} className="mb-4" />
|
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||||
)}
|
)}
|
||||||
<SimilarItems itemId={item?.Id} />
|
|
||||||
|
|
||||||
<View className="h-16"></View>
|
<OverviewText text={item.Overview} className="px-4 my-4" />
|
||||||
</View>
|
{item.Type !== "Program" && (
|
||||||
</ParallaxScrollView>
|
<>
|
||||||
</View>
|
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||||
);
|
|
||||||
});
|
{item.People && item.People.length > 0 && (
|
||||||
|
<View className="mb-4">
|
||||||
|
{item.People.slice(0, 3).map((person) => (
|
||||||
|
<MoreMoviesWithActor
|
||||||
|
currentItem={item}
|
||||||
|
key={person.Id}
|
||||||
|
actorId={person.Id!}
|
||||||
|
className="mb-4"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.Type === "Episode" && (
|
||||||
|
<CurrentSeries item={item} className="mb-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SimilarItems itemId={item.Id} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="h-16"></View>
|
||||||
|
</View>
|
||||||
|
</ParallaxScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ interface Props extends TouchableOpacityProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||||
|
if (item.CollectionType === "livetv") {
|
||||||
|
return `/(auth)/(tabs)/${from}/livetv`;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.Type === "Series") {
|
if (item.Type === "Series") {
|
||||||
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
{isLoading === false && data?.length === 0 && (
|
||||||
|
<View className="px-4">
|
||||||
|
<Text className="text-neutral-500">No items</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
@@ -98,6 +103,9 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
<MoviePoster item={item} />
|
<MoviePoster item={item} />
|
||||||
)}
|
)}
|
||||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||||
|
{item.Type === "Program" && (
|
||||||
|
<ContinueWatchingPoster item={item} />
|
||||||
|
)}
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -15,17 +15,13 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { TouchableOpacityProps, View } from "react-native";
|
import { TouchableOpacityProps, View } from "react-native";
|
||||||
import { getColors } from "react-native-image-colors";
|
import { getColors } from "react-native-image-colors";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
library: BaseItemDto;
|
library: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LibraryColor = {
|
|
||||||
dominantColor: string;
|
|
||||||
averageColor: string;
|
|
||||||
secondary: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type IconName = React.ComponentProps<typeof Ionicons>["name"];
|
type IconName = React.ComponentProps<typeof Ionicons>["name"];
|
||||||
|
|
||||||
const icons: Record<CollectionType, IconName> = {
|
const icons: Record<CollectionType, IconName> = {
|
||||||
@@ -48,12 +44,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const [imageInfo, setImageInfo] = useState<LibraryColor>({
|
|
||||||
dominantColor: "#fff",
|
|
||||||
averageColor: "#fff",
|
|
||||||
secondary: "#fff",
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = useMemo(
|
const url = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getPrimaryImageUrl({
|
getPrimaryImageUrl({
|
||||||
@@ -63,6 +53,10 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
[library]
|
[library]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If we want to use image colors for library cards
|
||||||
|
// const [color] = useAtom(itemThemeColorAtom)
|
||||||
|
// useImageColors({ url });
|
||||||
|
|
||||||
const { data: itemsCount } = useQuery({
|
const { data: itemsCount } = useQuery({
|
||||||
queryKey: ["library-count", library.Id],
|
queryKey: ["library-count", library.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -76,40 +70,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (url) {
|
|
||||||
getColors(url, {
|
|
||||||
fallback: "#fff",
|
|
||||||
cache: true,
|
|
||||||
key: url,
|
|
||||||
})
|
|
||||||
.then((colors) => {
|
|
||||||
let dominantColor: string = "#fff";
|
|
||||||
let averageColor: string = "#fff";
|
|
||||||
let secondary: string = "#fff";
|
|
||||||
|
|
||||||
if (colors.platform === "android") {
|
|
||||||
dominantColor = colors.dominant;
|
|
||||||
averageColor = colors.average;
|
|
||||||
secondary = colors.muted;
|
|
||||||
} else if (colors.platform === "ios") {
|
|
||||||
dominantColor = colors.primary;
|
|
||||||
averageColor = colors.background;
|
|
||||||
secondary = colors.detail;
|
|
||||||
}
|
|
||||||
|
|
||||||
setImageInfo({
|
|
||||||
dominantColor,
|
|
||||||
averageColor,
|
|
||||||
secondary,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Error getting colors", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [url]);
|
|
||||||
|
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|
||||||
if (settings?.libraryOptions?.display === "row") {
|
if (settings?.libraryOptions?.display === "row") {
|
||||||
|
|||||||
43
components/livetv/HourHeader.tsx
Normal file
43
components/livetv/HourHeader.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
export const HourHeader = ({ height }: { height: number }) => {
|
||||||
|
const now = new Date();
|
||||||
|
const currentHour = now.getHours();
|
||||||
|
const hoursRemaining = 24 - currentHour;
|
||||||
|
const hours = generateHours(currentHour, hoursRemaining);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex flex-row"
|
||||||
|
style={{
|
||||||
|
height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hours.map((hour, index) => (
|
||||||
|
<HourCell key={index} hour={hour} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HourCell = ({ hour }: { hour: Date }) => (
|
||||||
|
<View className="w-[200px] flex items-center justify-center bg-neutral-800">
|
||||||
|
<Text className="text-xs text-gray-600">
|
||||||
|
{hour.toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateHours = (startHour: number, count: number): Date[] => {
|
||||||
|
const now = new Date();
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const hour = new Date(now);
|
||||||
|
hour.setHours(startHour + i, 0, 0, 0);
|
||||||
|
return hour;
|
||||||
|
});
|
||||||
|
};
|
||||||
96
components/livetv/LiveTVGuideRow.tsx
Normal file
96
components/livetv/LiveTVGuideRow.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import { Dimensions, View } from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
|
||||||
|
export const LiveTVGuideRow = ({
|
||||||
|
channel,
|
||||||
|
programs,
|
||||||
|
scrollX = 0,
|
||||||
|
isVisible = true,
|
||||||
|
}: {
|
||||||
|
channel: BaseItemDto;
|
||||||
|
programs?: BaseItemDto[] | null;
|
||||||
|
scrollX?: number;
|
||||||
|
isVisible?: boolean;
|
||||||
|
}) => {
|
||||||
|
const positionRefs = useRef<{ [key: string]: number }>({});
|
||||||
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
|
||||||
|
const calculateWidth = (s?: string | null, e?: string | null) => {
|
||||||
|
if (!s || !e) return 0;
|
||||||
|
const start = new Date(s);
|
||||||
|
const end = new Date(e);
|
||||||
|
const duration = end.getTime() - start.getTime();
|
||||||
|
const minutes = duration / 60000;
|
||||||
|
const width = (minutes / 60) * 200;
|
||||||
|
return width;
|
||||||
|
};
|
||||||
|
|
||||||
|
const programsWithPositions = useMemo(() => {
|
||||||
|
let cumulativeWidth = 0;
|
||||||
|
return programs
|
||||||
|
?.filter((p) => p.ChannelId === channel.Id)
|
||||||
|
.map((p) => {
|
||||||
|
const width = calculateWidth(p.StartDate, p.EndDate);
|
||||||
|
const position = cumulativeWidth;
|
||||||
|
cumulativeWidth += width;
|
||||||
|
return { ...p, width, position };
|
||||||
|
});
|
||||||
|
}, [programs, channel.Id]);
|
||||||
|
|
||||||
|
const isCurrentlyLive = (program: BaseItemDto) => {
|
||||||
|
if (!program.StartDate || !program.EndDate) return false;
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(program.StartDate);
|
||||||
|
const end = new Date(program.EndDate);
|
||||||
|
return now >= start && now <= end;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return <View style={{ height: 64 }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={channel.ChannelNumber} className="flex flex-row h-16">
|
||||||
|
{programsWithPositions?.map((p) => (
|
||||||
|
<TouchableItemRouter item={p} key={p.Id}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: p.width,
|
||||||
|
height: "100%",
|
||||||
|
position: "absolute",
|
||||||
|
left: p.position,
|
||||||
|
backgroundColor: isCurrentlyLive(p)
|
||||||
|
? "rgba(255, 255, 255, 0.1)"
|
||||||
|
: "transparent",
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-center justify-center border border-neutral-800 overflow-hidden"
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginLeft:
|
||||||
|
p.width > screenWidth && scrollX > p.position
|
||||||
|
? scrollX - p.position
|
||||||
|
: 0,
|
||||||
|
}}
|
||||||
|
className="px-4 self-start"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
className="text-xs text-start self-start"
|
||||||
|
>
|
||||||
|
{p.Name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -85,7 +85,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
itemId: previousId,
|
itemId: previousId,
|
||||||
}),
|
}),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000 * 5,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
itemId: nextId,
|
itemId: nextId,
|
||||||
}),
|
}),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000 * 5,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [episodes, api, user?.Id, item]);
|
}, [episodes, api, user?.Id, item]);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Stack } from "expo-router";
|
|
||||||
import { Chromecast } from "../Chromecast";
|
|
||||||
import { HeaderBackButton } from "../common/HeaderBackButton";
|
import { HeaderBackButton } from "../common/HeaderBackButton";
|
||||||
|
|
||||||
const commonScreenOptions = {
|
const commonScreenOptions = {
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -22,13 +22,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.16.0",
|
"channel": "0.17.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.16.0",
|
"channel": "0.17.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -19,19 +19,30 @@ import { getColors } from "react-native-image-colors";
|
|||||||
* @param disabled - A boolean flag to disable color extraction.
|
* @param disabled - A boolean flag to disable color extraction.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export const useImageColors = (item?: BaseItemDto | null, disabled = false) => {
|
export const useImageColors = ({
|
||||||
|
item,
|
||||||
|
url,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
url?: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
const source = useMemo(() => {
|
const source = useMemo(() => {
|
||||||
if (!api || !item) return;
|
if (!api) return;
|
||||||
return getItemImage({
|
if (url) return { uri: url };
|
||||||
item,
|
else if (item)
|
||||||
api,
|
return getItemImage({
|
||||||
variant: "Primary",
|
item,
|
||||||
quality: 80,
|
api,
|
||||||
width: 300,
|
variant: "Primary",
|
||||||
});
|
quality: 80,
|
||||||
|
width: 300,
|
||||||
|
});
|
||||||
|
else return;
|
||||||
}, [api, item]);
|
}, [api, item]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -18,12 +18,14 @@
|
|||||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
||||||
"@expo/react-native-action-sheet": "^4.1.0",
|
"@expo/react-native-action-sheet": "^4.1.0",
|
||||||
"@expo/vector-icons": "^14.0.3",
|
"@expo/vector-icons": "^14.0.3",
|
||||||
|
"@futurejj/react-native-visibility-sensor": "^1.3.4",
|
||||||
"@gorhom/bottom-sheet": "^4",
|
"@gorhom/bottom-sheet": "^4",
|
||||||
"@jellyfin/sdk": "^0.10.0",
|
"@jellyfin/sdk": "^0.10.0",
|
||||||
"@kesha-antonov/react-native-background-downloader": "^3.2.1",
|
"@kesha-antonov/react-native-background-downloader": "^3.2.1",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@react-native-community/netinfo": "11.3.1",
|
"@react-native-community/netinfo": "11.3.1",
|
||||||
"@react-native-menu/menu": "^1.1.3",
|
"@react-native-menu/menu": "^1.1.3",
|
||||||
|
"@react-navigation/material-top-tabs": "^6.6.14",
|
||||||
"@react-navigation/native": "^6.0.2",
|
"@react-navigation/native": "^6.0.2",
|
||||||
"@shopify/flash-list": "1.6.4",
|
"@shopify/flash-list": "1.6.4",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
@@ -56,6 +58,7 @@
|
|||||||
"expo-updates": "~0.25.26",
|
"expo-updates": "~0.25.26",
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"ffmpeg-kit-react-native": "^6.0.2",
|
"ffmpeg-kit-react-native": "^6.0.2",
|
||||||
|
"install": "^0.13.0",
|
||||||
"jotai": "^2.10.0",
|
"jotai": "^2.10.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
@@ -72,11 +75,13 @@
|
|||||||
"react-native-ios-context-menu": "^2.5.1",
|
"react-native-ios-context-menu": "^2.5.1",
|
||||||
"react-native-ios-utilities": "^4.4.5",
|
"react-native-ios-utilities": "^4.4.5",
|
||||||
"react-native-mmkv": "^2.12.2",
|
"react-native-mmkv": "^2.12.2",
|
||||||
|
"react-native-pager-view": "^6.4.1",
|
||||||
"react-native-reanimated": "~3.15.0",
|
"react-native-reanimated": "~3.15.0",
|
||||||
"react-native-reanimated-carousel": "4.0.0-canary.15",
|
"react-native-reanimated-carousel": "4.0.0-canary.15",
|
||||||
"react-native-safe-area-context": "4.10.5",
|
"react-native-safe-area-context": "4.10.5",
|
||||||
"react-native-screens": "~3.34.0",
|
"react-native-screens": "~3.34.0",
|
||||||
"react-native-svg": "15.2.0",
|
"react-native-svg": "15.2.0",
|
||||||
|
"react-native-tab-view": "^3.5.2",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-uuid": "^2.0.2",
|
"react-native-uuid": "^2.0.2",
|
||||||
"react-native-video": "^6.6.4",
|
"react-native-video": "^6.6.4",
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.16.0" },
|
clientInfo: { name: "Streamyfin", version: "0.17.0" },
|
||||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -86,7 +86,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.16.0"`,
|
}, DeviceId="${deviceId}", Version="0.17.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -128,10 +128,17 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getMediaInfoApi(api!).getPlaybackInfo({
|
// Support live tv
|
||||||
itemId: state.item.Id,
|
const res =
|
||||||
userId: user.Id,
|
state.item.Type !== "Program"
|
||||||
});
|
? await getMediaInfoApi(api!).getPlaybackInfo({
|
||||||
|
itemId: state.item.Id,
|
||||||
|
userId: user.Id,
|
||||||
|
})
|
||||||
|
: await getMediaInfoApi(api!).getPlaybackInfo({
|
||||||
|
itemId: state.item.ChannelId!,
|
||||||
|
userId: user.Id,
|
||||||
|
});
|
||||||
|
|
||||||
await postCapabilities({
|
await postCapabilities({
|
||||||
api,
|
api,
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||||
|
import { getItemsApi, getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { isPlainObject } from "lodash";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
|
||||||
export const getStreamUrl = async ({
|
export const getStreamUrl = async ({
|
||||||
api,
|
api,
|
||||||
@@ -19,7 +22,6 @@ export const getStreamUrl = async ({
|
|||||||
audioStreamIndex = 0,
|
audioStreamIndex = 0,
|
||||||
subtitleStreamIndex = undefined,
|
subtitleStreamIndex = undefined,
|
||||||
forceDirectPlay = false,
|
forceDirectPlay = false,
|
||||||
height,
|
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
}: {
|
}: {
|
||||||
api: Api | null | undefined;
|
api: Api | null | undefined;
|
||||||
@@ -27,24 +29,55 @@ export const getStreamUrl = async ({
|
|||||||
userId: string | null | undefined;
|
userId: string | null | undefined;
|
||||||
startTimeTicks: number;
|
startTimeTicks: number;
|
||||||
maxStreamingBitrate?: number;
|
maxStreamingBitrate?: number;
|
||||||
sessionData: PlaybackInfoResponse;
|
sessionData?: PlaybackInfoResponse | null;
|
||||||
deviceProfile: any;
|
deviceProfile: any;
|
||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
subtitleStreamIndex?: number;
|
subtitleStreamIndex?: number;
|
||||||
forceDirectPlay?: boolean;
|
forceDirectPlay?: boolean;
|
||||||
height?: number;
|
height?: number;
|
||||||
mediaSourceId: string | null;
|
mediaSourceId?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
if (!api || !userId || !item?.Id || !mediaSourceId) {
|
if (!api || !userId || !item?.Id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mediaSource: MediaSourceInfo | undefined;
|
||||||
|
let url: string | null | undefined;
|
||||||
|
|
||||||
|
if (item.Type === "Program") {
|
||||||
|
const res0 = await getMediaInfoApi(api).getPlaybackInfo(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
itemId: item.ChannelId!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
params: {
|
||||||
|
startTimeTicks: 0,
|
||||||
|
isPlayback: true,
|
||||||
|
autoOpenLiveStream: true,
|
||||||
|
maxStreamingBitrate,
|
||||||
|
audioStreamIndex,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
deviceProfile,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const mediaSourceId = res0.data.MediaSources?.[0].Id;
|
||||||
|
const liveStreamId = res0.data.MediaSources?.[0].LiveStreamId;
|
||||||
|
|
||||||
|
const transcodeUrl = res0.data.MediaSources?.[0].TranscodingUrl;
|
||||||
|
|
||||||
|
console.log("transcodeUrl", transcodeUrl);
|
||||||
|
|
||||||
|
if (transcodeUrl) return `${api.basePath}${transcodeUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
const itemId = item.Id;
|
const itemId = item.Id;
|
||||||
|
|
||||||
/**
|
const res2 = await api.axiosInstance.post(
|
||||||
* Build the stream URL for videos
|
|
||||||
*/
|
|
||||||
const response = await api.axiosInstance.post(
|
|
||||||
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
|
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
|
||||||
{
|
{
|
||||||
DeviceProfile: deviceProfile,
|
DeviceProfile: deviceProfile,
|
||||||
@@ -67,23 +100,13 @@ export const getStreamUrl = async ({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
|
mediaSource = res2.data.MediaSources.find(
|
||||||
(source: MediaSourceInfo) => source.Id === mediaSourceId
|
(source: MediaSourceInfo) => source.Id === mediaSourceId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mediaSource) {
|
if (mediaSource?.SupportsDirectPlay || forceDirectPlay === true) {
|
||||||
throw new Error("No media source");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionData.PlaySessionId) {
|
|
||||||
throw new Error("no PlaySessionId");
|
|
||||||
}
|
|
||||||
|
|
||||||
let url: string | null | undefined;
|
|
||||||
|
|
||||||
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
|
||||||
if (item.MediaType === "Video") {
|
if (item.MediaType === "Video") {
|
||||||
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData?.PlaySessionId}&mediaSourceId=${mediaSource?.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||||
} else if (item.MediaType === "Audio") {
|
} else if (item.MediaType === "Audio") {
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
@@ -95,7 +118,7 @@ export const getStreamUrl = async ({
|
|||||||
TranscodingProtocol: "hls",
|
TranscodingProtocol: "hls",
|
||||||
AudioCodec: "aac",
|
AudioCodec: "aac",
|
||||||
api_key: api.accessToken,
|
api_key: api.accessToken,
|
||||||
PlaySessionId: sessionData.PlaySessionId,
|
PlaySessionId: sessionData?.PlaySessionId || "",
|
||||||
StartTimeTicks: "0",
|
StartTimeTicks: "0",
|
||||||
EnableRedirection: "true",
|
EnableRedirection: "true",
|
||||||
EnableRemoteMedia: "false",
|
EnableRemoteMedia: "false",
|
||||||
@@ -104,18 +127,11 @@ export const getStreamUrl = async ({
|
|||||||
api.basePath
|
api.basePath
|
||||||
}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
||||||
}
|
}
|
||||||
} else if (mediaSource.TranscodingUrl) {
|
} else if (mediaSource?.TranscodingUrl) {
|
||||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!url) throw new Error("No url");
|
if (!url) throw new Error("No url");
|
||||||
|
|
||||||
console.log(
|
|
||||||
mediaSource.VideoType,
|
|
||||||
mediaSource.Container,
|
|
||||||
mediaSource.TranscodingContainer,
|
|
||||||
mediaSource.TranscodingSubProtocol
|
|
||||||
);
|
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user