diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx new file mode 100644 index 00000000..e82e8c2b --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx @@ -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, + MaterialTopTabNavigationEventMap +>(Navigator); + +const Layout = () => { + return ( + + + + + + + + + ); +}; + +export default Layout; diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/channels.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/channels.tsx new file mode 100644 index 00000000..f2a73887 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/channels.tsx @@ -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 ( + + ( + + + + + {item.Name} + + )} + /> + + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx new file mode 100644 index 00000000..9bbae531 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx @@ -0,0 +1,11 @@ +import { Text } from "@/components/common/Text"; +import React from "react"; +import { View } from "react-native"; + +export default function page() { + return ( + + Not implemented + + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/programs.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/programs.tsx new file mode 100644 index 00000000..27ed4385 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/programs.tsx @@ -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 ( + + + { + 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" + /> + { + 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" + /> + { + 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" + /> + { + 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" + /> + { + 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" + /> + { + 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" + /> + + + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/recordings.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/recordings.tsx new file mode 100644 index 00000000..9bbae531 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/recordings.tsx @@ -0,0 +1,11 @@ +import { Text } from "@/components/common/Text"; +import React from "react"; +import { View } from "react-native"; + +export default function page() { + return ( + + Not implemented + + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/schedule.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/schedule.tsx new file mode 100644 index 00000000..9bbae531 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/schedule.tsx @@ -0,0 +1,11 @@ +import { Text } from "@/components/common/Text"; +import React from "react"; +import { View } from "react-native"; + +export default function page() { + return ( + + Not implemented + + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/series.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/series.tsx new file mode 100644 index 00000000..9bbae531 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/series.tsx @@ -0,0 +1,11 @@ +import { Text } from "@/components/common/Text"; +import React from "react"; +import { View } from "react-native"; + +export default function page() { + return ( + + Not implemented + + ); +} diff --git a/bun.lockb b/bun.lockb index de0709bd..133145f0 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx index 2bd3e3ea..46d01371 100644 --- a/components/ContinueWatchingPoster.tsx +++ b/components/ContinueWatchingPoster.tsx @@ -40,6 +40,12 @@ const ContinueWatchingPoster: React.FC = ({ 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( diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index e40d895f..66e530bd 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -155,8 +155,12 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { item && ( - - + {item.Type !== "Program" && ( + <> + + + + )} ), }); @@ -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, }); diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index b81afafe..04d3a12f 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -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}`; } diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 0d47d112..4cf596dd 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -44,6 +44,11 @@ export const ScrollingCollectionList: React.FC = ({ {title} + {isLoading === false && data?.length === 0 && ( + + No items + + )} {isLoading ? ( = ({ )} {item.Type === "Series" && } + {item.Type === "Program" && ( + + )} ))} diff --git a/package.json b/package.json index 7376484b..8d2bb311 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index c83d3d6b..210df3c6 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -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",