first commit

This commit is contained in:
Fredrik Burmester
2024-10-05 09:17:54 +02:00
parent d064622055
commit 387add4c83
14 changed files with 384 additions and 24 deletions

View File

@@ -0,0 +1,46 @@
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
import type {
MaterialTopTabNavigationOptions,
MaterialTopTabNavigationEventMap,
} from "@react-navigation/material-top-tabs";
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
import { withLayoutContext } from "expo-router";
const { Navigator } = createMaterialTopTabNavigator();
export const Tab = withLayoutContext<
MaterialTopTabNavigationOptions,
typeof Navigator,
TabNavigationState<ParamListBase>,
MaterialTopTabNavigationEventMap
>(Navigator);
const Layout = () => {
return (
<Tab
initialRouteName="programs"
keyboardDismissMode="none"
screenOptions={{
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.Screen name="schedule" />
<Tab.Screen name="series" />
</Tab>
);
};
export default Layout;

View File

@@ -0,0 +1,56 @@
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { ItemPoster } from "@/components/posters/ItemPoster";
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 { Image } from "expo-image";
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 () => {
if (!api) return [];
const res = await getLiveTvApi(api).getLiveTvChannels({
startIndex: 0,
fields: ["PrimaryImageAspectRatio"],
limit: 100,
userId: user?.Id,
});
return res.data.Items;
},
});
return (
<View className="flex flex-1">
<FlashList
data={channels}
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>
);
}

View File

@@ -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>
<Text>Not implemented</Text>
</View>
);
}

View File

@@ -0,0 +1,154 @@
import { Text } from "@/components/common/Text";
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 { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React from "react";
import {
RefreshControl,
ScrollView,
SectionListComponent,
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>
);
}

View File

@@ -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>
<Text>Not implemented</Text>
</View>
);
}

View File

@@ -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>
<Text>Not implemented</Text>
</View>
);
}

View File

@@ -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>
<Text>Not implemented</Text>
</View>
);
}

BIN
bun.lockb

Binary file not shown.

View File

@@ -40,6 +40,12 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
else
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]);
const [progress, setProgress] = useState(

View File

@@ -155,8 +155,12 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
item && (
<View className="flex flex-row items-center space-x-2">
<Chromecast background="blur" width={22} height={22} />
<DownloadItem item={item} />
<PlayedStatus item={item} />
{item.Type !== "Program" && (
<>
<DownloadItem item={item} />
<PlayedStatus item={item} />
</>
)}
</View>
),
});
@@ -199,8 +203,24 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
settings,
],
queryFn: async () => {
if (!api || !user?.Id || !sessionData || !selectedMediaSource?.Id)
if (!api || !user?.Id) {
console.warn("No api, userid or selected media source", {
api: api,
user: user,
});
return null;
}
if (
item?.Type !== "Program" &&
(!sessionData || !selectedMediaSource?.Id)
) {
console.warn("No session data or media source", {
sessionData: sessionData,
selectedMediaSource: selectedMediaSource,
});
return null;
}
let deviceProfile: any = iosFmp4;
@@ -212,6 +232,8 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
deviceProfile = old;
}
console.log("playbackUrl...");
const url = await getStreamUrl({
api,
userId: user.Id,
@@ -224,14 +246,14 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
subtitleStreamIndex: selectedSubtitleStream,
forceDirectPlay: settings?.forceDirectPlay,
height: maxBitrate.height,
mediaSourceId: selectedMediaSource.Id,
mediaSourceId: selectedMediaSource?.Id,
});
console.info("Stream URL:", url);
return url;
},
enabled: !!sessionData && !!api && !!user?.Id && !!item?.Id,
enabled: !!api && !!user?.Id && !!item?.Id,
staleTime: 0,
});

View File

@@ -9,6 +9,10 @@ interface Props extends TouchableOpacityProps {
}
export const itemRouter = (item: BaseItemDto, from: string) => {
if (item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`;
}
if (item.Type === "Series") {
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
}

View File

@@ -44,6 +44,11 @@ export const ScrollingCollectionList: React.FC<Props> = ({
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
{title}
</Text>
{isLoading === false && data?.length === 0 && (
<View className="px-4">
<Text className="text-neutral-500">No items</Text>
</View>
)}
{isLoading ? (
<View
className={`
@@ -98,6 +103,9 @@ export const ScrollingCollectionList: React.FC<Props> = ({
<MoviePoster item={item} />
)}
{item.Type === "Series" && <SeriesPoster item={item} />}
{item.Type === "Program" && (
<ContinueWatchingPoster item={item} />
)}
<ItemCardText item={item} />
</TouchableItemRouter>
))}

View File

@@ -24,6 +24,7 @@
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.3.1",
"@react-native-menu/menu": "^1.1.3",
"@react-navigation/material-top-tabs": "^6.6.14",
"@react-navigation/native": "^6.0.2",
"@shopify/flash-list": "1.6.4",
"@tanstack/react-query": "^5.56.2",
@@ -56,6 +57,7 @@
"expo-updates": "~0.25.26",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"install": "^0.13.0",
"jotai": "^2.10.0",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
@@ -72,11 +74,13 @@
"react-native-ios-context-menu": "^2.5.1",
"react-native-ios-utilities": "^4.4.5",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "^6.4.1",
"react-native-reanimated": "~3.15.0",
"react-native-reanimated-carousel": "4.0.0-canary.15",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "~3.34.0",
"react-native-svg": "15.2.0",
"react-native-tab-view": "^3.5.2",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2",
"react-native-video": "^6.6.4",

View File

@@ -7,6 +7,7 @@ import {
} from "@jellyfin/sdk/lib/generated-client/models";
import { getAuthHeaders } from "../jellyfin";
import iosFmp4 from "@/utils/profiles/iosFmp4";
import { getItemsApi, getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
export const getStreamUrl = async ({
api,
@@ -19,7 +20,6 @@ export const getStreamUrl = async ({
audioStreamIndex = 0,
subtitleStreamIndex = undefined,
forceDirectPlay = false,
height,
mediaSourceId,
}: {
api: Api | null | undefined;
@@ -27,24 +27,40 @@ export const getStreamUrl = async ({
userId: string | null | undefined;
startTimeTicks: number;
maxStreamingBitrate?: number;
sessionData: PlaybackInfoResponse;
sessionData?: PlaybackInfoResponse | null;
deviceProfile: any;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
forceDirectPlay?: boolean;
height?: number;
mediaSourceId: string | null;
mediaSourceId?: string | null;
}) => {
if (!api || !userId || !item?.Id || !mediaSourceId) {
if (!api || !userId || !item?.Id) {
return null;
}
console.log("[0] getStreamUrl ~");
const itemId = item.Id;
/**
* Build the stream URL for videos
*/
const response = await api.axiosInstance.post(
console.log("[1] getStreamUrl ~");
const res1 = await api.axiosInstance.post(
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
{
UserId: itemId,
StartTimeTicks: 0,
IsPlayback: true,
AutoOpenLiveStream: true,
MaxStreamingBitrate: 140000000,
},
{
headers: getAuthHeaders(api),
}
);
console.log("[2] getStreamUrl ~", res1.status, res1.statusText);
const res2 = await api.axiosInstance.post(
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
{
DeviceProfile: deviceProfile,
@@ -67,23 +83,23 @@ export const getStreamUrl = async ({
}
);
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
(source: MediaSourceInfo) => source.Id === mediaSourceId
console.log("[3] getStreamUrl ~");
console.log(
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
res2.status,
res2.statusText
);
if (!mediaSource) {
throw new Error("No media source");
}
if (!sessionData.PlaySessionId) {
throw new Error("no PlaySessionId");
}
const mediaSource: MediaSourceInfo = res2.data.MediaSources.find(
(source: MediaSourceInfo) => source.Id === mediaSourceId
);
let url: string | null | undefined;
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
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") {
const searchParams = new URLSearchParams({
UserId: userId,
@@ -95,7 +111,7 @@ export const getStreamUrl = async ({
TranscodingProtocol: "hls",
AudioCodec: "aac",
api_key: api.accessToken,
PlaySessionId: sessionData.PlaySessionId,
PlaySessionId: sessionData?.PlaySessionId || "",
StartTimeTicks: "0",
EnableRedirection: "true",
EnableRemoteMedia: "false",