diff --git a/README.md b/README.md
index 8bddf816..360f4949 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
## 🌟 Features
+
- 🚀 **Skp intro / credits support**
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
@@ -61,7 +62,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
## Get it now
@@ -135,7 +136,7 @@ Key points of the MPL-2.0:
## 🌐 Connect with Us
-Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/aJvAYeycyY)
+Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE)
If you have questions or need support, feel free to reach out:
@@ -153,3 +154,7 @@ I'd like to thank the following people and projects for their contributions to S
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
- The Jellyfin devs for always being helpful in the Discord.
+
+## Star History
+
+[](https://star-history.com/#fredrikburmester/streamyfin&Date)
diff --git a/app.json b/app.json
index c7d3db86..de37a56c 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
- "version": "0.16.0",
+ "version": "0.17.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -10,7 +10,7 @@
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
- "backgroundColor": "#29164B"
+ "backgroundColor": "#2E2E2E"
},
"jsEngine": "hermes",
"assetBundlePatterns": ["**/*"],
@@ -33,9 +33,9 @@
},
"android": {
"jsEngine": "hermes",
- "versionCode": 42,
+ "versionCode": 43,
"adaptiveIcon": {
- "foregroundImage": "./assets/images/icon.png"
+ "foregroundImage": "./assets/images/adaptive_icon.png"
},
"package": "com.fredrikburmester.streamyfin",
"permissions": [
diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx
index 763b89c9..f51ecbf5 100644
--- a/app/(auth)/(tabs)/(home)/_layout.tsx
+++ b/app/(auth)/(tabs)/(home)/_layout.tsx
@@ -1,12 +1,14 @@
import { Chromecast } from "@/components/Chromecast";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
+import { useDownload } from "@/providers/DownloadProvider";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native";
export default function IndexLayout() {
const router = useRouter();
+
return (
(null);
const [loadingRetry, setLoadingRetry] = useState(false);
+ const { downloadedFiles } = useDownload();
+ const navigation = useNavigation();
+
+ useEffect(() => {
+ const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
+ navigation.setOptions({
+ headerLeft: () => (
+ {
+ router.push("/(auth)/downloads");
+ }}
+ className="p-2"
+ >
+
+
+ ),
+ });
+ }, [downloadedFiles, navigation, router]);
+
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
@@ -141,7 +169,7 @@ export default function index() {
setLoading(true);
await queryClient.invalidateQueries();
setLoading(false);
- }, [queryClient, user?.Id]);
+ }, []);
const createCollectionConfig = useCallback(
(
@@ -340,33 +368,38 @@ export default function index() {
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
+ paddingBottom: 16,
+ }}
+ style={{
+ marginBottom: TAB_HEIGHT,
}}
- className="flex flex-col space-y-4 mb-20"
>
-
+
+
- {sections.map((section, index) => {
- if (section.type === "ScrollingCollectionList") {
- return (
-
- );
- } else if (section.type === "MediaListSection") {
- return (
-
- );
- }
- return null;
- })}
+ {sections.map((section, index) => {
+ if (section.type === "ScrollingCollectionList") {
+ return (
+
+ );
+ } else if (section.type === "MediaListSection") {
+ return (
+
+ );
+ }
+ return null;
+ })}
+
);
}
diff --git a/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx
index c496ea88..071d9127 100644
--- a/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx
@@ -1,15 +1,94 @@
+import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent";
-import { Stack, useLocalSearchParams } from "expo-router";
-import React from "react";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+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 [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
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 (
+
+ Could not load item
+
+ );
+
return (
- <>
-
-
- >
+
+
+
+
+
+
+
+
+
+
+
+ {item && }
+
);
};
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..7225e677
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx
@@ -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,
+ 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..dd1c1f85
--- /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 { 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 (
+
+ (
+
+
+
+
+ {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..101b3fe8
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx
@@ -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(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 (
+
+
+
+
+
+
+
+
+
+ {channels?.Items?.map((c, i) => (
+
+
+
+ ))}
+
+ {
+ setScrollX(e.nativeEvent.contentOffset.x);
+ }}
+ >
+
+
+ {channels?.Items?.map((c, i) => (
+
+ ))}
+
+
+
+
+ );
+}
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..00c1235a
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/programs.tsx
@@ -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 (
+
+
+ {
+ 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..6e3f660e
--- /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 (
+
+ Coming soon
+
+ );
+}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 9629f22d..828f4005 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -337,6 +337,7 @@ function Layout() {
name="(auth)/play"
options={{
headerShown: false,
+ autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
@@ -345,6 +346,7 @@ function Layout() {
name="(auth)/play-music"
options={{
headerShown: false,
+ autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
diff --git a/assets/images/adaptive_icon.png b/assets/images/adaptive_icon.png
new file mode 100644
index 00000000..8443e717
Binary files /dev/null and b/assets/images/adaptive_icon.png differ
diff --git a/assets/images/icon.jpg b/assets/images/icon.jpg
deleted file mode 100644
index 3c1892b4..00000000
Binary files a/assets/images/icon.jpg and /dev/null differ
diff --git a/assets/images/icon.png b/assets/images/icon.png
index 4382a2ff..7011bb09 100644
Binary files a/assets/images/icon.png and b/assets/images/icon.png differ
diff --git a/assets/images/icon_512x512.jpg b/assets/images/icon_512x512.jpg
deleted file mode 100644
index 24cbd3c0..00000000
Binary files a/assets/images/icon_512x512.jpg and /dev/null differ
diff --git a/assets/images/splash.jpg b/assets/images/splash.jpg
deleted file mode 100644
index 96fabe80..00000000
Binary files a/assets/images/splash.jpg and /dev/null differ
diff --git a/assets/images/splash.png b/assets/images/splash.png
index 2d7bf575..106487b2 100644
Binary files a/assets/images/splash.png and b/assets/images/splash.png differ
diff --git a/bun.lockb b/bun.lockb
index c717c667..85cc964e 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx
index 2bd3e3ea..3a1899ec 100644
--- a/components/ContinueWatchingPoster.tsx
+++ b/components/ContinueWatchingPoster.tsx
@@ -40,11 +40,26 @@ 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(
- item.UserData?.PlayedPercentage || 0
- );
+ const progress = useMemo(() => {
+ 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)
return (
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index 37ebcb0e..e6c7617b 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -30,6 +30,7 @@ import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { toast } from "sonner-native";
+import iosFmp4 from "@/utils/profiles/iosFmp4";
interface DownloadProps extends ViewProps {
item: BaseItemDto;
@@ -82,7 +83,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
);
}
- let deviceProfile: any = ios;
+ let deviceProfile: any = iosFmp4;
if (settings?.deviceProfile === "Native") {
deviceProfile = native;
diff --git a/components/FullScreenVideoPlayer.tsx b/components/FullScreenVideoPlayer.tsx
index d19a65eb..9453c2b3 100644
--- a/components/FullScreenVideoPlayer.tsx
+++ b/components/FullScreenVideoPlayer.tsx
@@ -32,7 +32,7 @@ import {
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import Video, { OnProgressData } from "react-native-video";
+import Video, { OnProgressData, ReactVideoProps } from "react-native-video";
import { Text } from "./common/Text";
import { itemRouter } from "./common/TouchableItemRouter";
import { Loader } from "./Loader";
@@ -199,8 +199,8 @@ export const FullScreenVideoPlayer: React.FC = () => {
});
}, [currentlyPlaying?.item, api]);
- const videoSource = useMemo(() => {
- if (!api || !currentlyPlaying || !poster) return null;
+ const videoSource: ReactVideoProps["source"] = useMemo(() => {
+ if (!api || !currentlyPlaying || !poster) return undefined;
const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
: 0;
@@ -342,24 +342,25 @@ export const FullScreenVideoPlayer: React.FC = () => {
},
]}
>
- {videoSource && (
- (max.value = secondsToTicks(data.duration))}
- onError={handleVideoError}
- playWhenInactive={true}
- allowsExternalPlayback={true}
- playInBackground={true}
- pictureInPicture={true}
- showNotificationControls={true}
- ignoreSilentSwitch="ignore"
- fullscreen={false}
- />
- )}
+ (max.value = secondsToTicks(data.duration))}
+ onError={handleVideoError}
+ playWhenInactive={true}
+ allowsExternalPlayback={true}
+ playInBackground={true}
+ pictureInPicture={true}
+ showNotificationControls={true}
+ ignoreSilentSwitch="ignore"
+ fullscreen={false}
+ onVideoTracks={(d) => {
+ console.log("onVideoTracks ~", d);
+ }}
+ />
{(showControls || isBuffering) && (
diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx
index 47f97e9a..1634be65 100644
--- a/components/ItemContent.tsx
+++ b/components/ItemContent.tsx
@@ -14,294 +14,242 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
-import { getItemImage } from "@/utils/getItemImage";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
-import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { chromecastProfile } from "@/utils/profiles/chromecast";
-import ios from "@/utils/profiles/ios";
+import iosFmp4 from "@/utils/profiles/iosFmp4";
import native from "@/utils/profiles/native";
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 { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
-import { Stack, useNavigation } from "expo-router";
+import { useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
-import Animated, {
- runOnJS,
- useAnimatedStyle,
- useSharedValue,
- withTiming,
-} from "react-native-reanimated";
+import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
-import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
-export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
+export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
+ ({ item }) => {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
- const opacity = useSharedValue(0);
- const castDevice = useCastDevice();
- const navigation = useNavigation();
- const [settings] = useSettings();
- const [selectedMediaSource, setSelectedMediaSource] =
- useState(null);
- const [selectedAudioStream, setSelectedAudioStream] = useState(-1);
- const [selectedSubtitleStream, setSelectedSubtitleStream] =
- useState(-1);
- const [maxBitrate, setMaxBitrate] = useState({
- key: "Max",
- value: undefined,
- });
+ const castDevice = useCastDevice();
+ const navigation = useNavigation();
+ const [settings] = useSettings();
+ const [selectedMediaSource, setSelectedMediaSource] =
+ useState(null);
+ const [selectedAudioStream, setSelectedAudioStream] = useState(-1);
+ const [selectedSubtitleStream, setSelectedSubtitleStream] =
+ useState(-1);
+ const [maxBitrate, setMaxBitrate] = useState({
+ key: "Max",
+ value: undefined,
+ });
- const [loadingLogo, setLoadingLogo] = useState(true);
+ const [loadingLogo, setLoadingLogo] = useState(true);
- const [orientation, setOrientation] = useState(
- ScreenOrientation.Orientation.PORTRAIT_UP
- );
-
- useEffect(() => {
- const subscription = ScreenOrientation.addOrientationChangeListener(
- (event) => {
- setOrientation(event.orientationInfo.orientation);
- }
+ const [orientation, setOrientation] = useState(
+ ScreenOrientation.Orientation.PORTRAIT_UP
);
- ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
- setOrientation(initialOrientation);
- });
+ useEffect(() => {
+ const subscription = ScreenOrientation.addOrientationChangeListener(
+ (event) => {
+ setOrientation(event.orientationInfo.orientation);
+ }
+ );
- return () => {
- ScreenOrientation.removeOrientationChangeListener(subscription);
- };
- }, []);
-
- 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,
+ ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
+ setOrientation(initialOrientation);
});
- return res;
- },
- enabled: !!id && !!api,
- staleTime: 60 * 1000 * 5,
- });
+ return () => {
+ ScreenOrientation.removeOrientationChangeListener(subscription);
+ };
+ }, []);
- const [localItem, setLocalItem] = useState(item);
- useImageColors(item);
+ const headerHeightRef = useRef(400);
- useEffect(() => {
- if (item) {
- if (localItem) {
- // Fade out current item
- fadeOut(() => {
- // Update local item after fade out
- setLocalItem(item);
- // Then fade in
- fadeIn();
+ useImageColors({ item });
+
+ useEffect(() => {
+ navigation.setOptions({
+ headerRight: () =>
+ item && (
+
+
+ {item.Type !== "Program" && (
+ <>
+
+
+ >
+ )}
+
+ ),
+ });
+ }, [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(() => {
- navigation.setOptions({
- headerRight: () =>
- item && (
-
-
-
-
-
- ),
+ console.info("Stream URL:", url);
+
+ return url;
+ },
+ enabled: !!api && !!user?.Id && !!item.Id,
+ staleTime: 0,
});
- }, [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 logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
- 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,
- });
+ const loading = useMemo(() => {
+ return Boolean(logoUrl && loadingLogo);
+ }, [loadingLogo, logoUrl]);
- return playbackData.data;
- },
- enabled: !!item?.Id && !!api && !!user?.Id,
- staleTime: 0,
- });
+ const insets = useSafeAreaInsets();
- const { data: playbackUrl } = useQuery({
- queryKey: [
- "playbackUrl",
- item?.Id,
- maxBitrate,
- castDevice,
- selectedMediaSource,
- selectedAudioStream,
- selectedSubtitleStream,
- settings,
- ],
- queryFn: async () => {
- if (!api || !user?.Id || !sessionData || !selectedMediaSource?.Id)
- return null;
-
- let deviceProfile: any = ios;
-
- 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 (
-
- {loading && (
-
-
-
- )}
-
-
- {localItem && (
+ return (
+
+
+
- )}
-
- >
- }
- logo={
- <>
- {logoUrl ? (
- setLoadingLogo(false)}
- onError={() => setLoadingLogo(false)}
- />
- ) : null}
- >
- }
- >
-
-
-
-
- {localItem ? (
+
+ >
+ }
+ logo={
+ <>
+ {logoUrl ? (
+ setLoadingLogo(false)}
+ onError={() => setLoadingLogo(false)}
+ />
+ ) : null}
+ >
+ }
+ >
+
+
+
+ {item.Type !== "Program" && (
= React.memo(({ id }) => {
/>
@@ -330,46 +278,45 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
>
)}
- ) : (
-
-
-
-
)}
-
-
-
-
- {item?.Type === "Episode" && (
-
- )}
-
-
-
-
-
- {item?.People && item.People.length > 0 && (
-
- {item.People.slice(0, 3).map((person) => (
-
- ))}
+
- )}
- {item?.Type === "Episode" && (
-
- )}
-
+ {item.Type === "Episode" && (
+
+ )}
-
-
-
-
- );
-});
+
+ {item.Type !== "Program" && (
+ <>
+
+
+ {item.People && item.People.length > 0 && (
+
+ {item.People.slice(0, 3).map((person) => (
+
+ ))}
+
+ )}
+
+ {item.Type === "Episode" && (
+
+ )}
+
+
+ >
+ )}
+
+
+
+
+
+ );
+ }
+);
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/downloads/SeriesCard.tsx b/components/downloads/SeriesCard.tsx
index 5fe67611..5c46eb50 100644
--- a/components/downloads/SeriesCard.tsx
+++ b/components/downloads/SeriesCard.tsx
@@ -44,7 +44,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
{seasonItems.sort(sortByIndex)?.map((item, index) => (
-
+
))}
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/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx
index 10a925c8..58aa9ad6 100644
--- a/components/library/LibraryItemCard.tsx
+++ b/components/library/LibraryItemCard.tsx
@@ -15,17 +15,13 @@ import { useEffect, useMemo, useState } from "react";
import { TouchableOpacityProps, View } from "react-native";
import { getColors } from "react-native-image-colors";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
+import { useImageColors } from "@/hooks/useImageColors";
+import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
interface Props extends TouchableOpacityProps {
library: BaseItemDto;
}
-type LibraryColor = {
- dominantColor: string;
- averageColor: string;
- secondary: string;
-};
-
type IconName = React.ComponentProps["name"];
const icons: Record = {
@@ -48,12 +44,6 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => {
const [user] = useAtom(userAtom);
const [settings] = useSettings();
- const [imageInfo, setImageInfo] = useState({
- dominantColor: "#fff",
- averageColor: "#fff",
- secondary: "#fff",
- });
-
const url = useMemo(
() =>
getPrimaryImageUrl({
@@ -63,6 +53,10 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => {
[library]
);
+ // If we want to use image colors for library cards
+ // const [color] = useAtom(itemThemeColorAtom)
+ // useImageColors({ url });
+
const { data: itemsCount } = useQuery({
queryKey: ["library-count", library.Id],
queryFn: async () => {
@@ -76,40 +70,6 @@ export const LibraryItemCard: React.FC = ({ 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 (settings?.libraryOptions?.display === "row") {
diff --git a/components/livetv/HourHeader.tsx b/components/livetv/HourHeader.tsx
new file mode 100644
index 00000000..99344e43
--- /dev/null
+++ b/components/livetv/HourHeader.tsx
@@ -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 (
+
+ {hours.map((hour, index) => (
+
+ ))}
+
+ );
+};
+
+const HourCell = ({ hour }: { hour: Date }) => (
+
+
+ {hour.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+
+);
+
+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;
+ });
+};
diff --git a/components/livetv/LiveTVGuideRow.tsx b/components/livetv/LiveTVGuideRow.tsx
new file mode 100644
index 00000000..cbb70d19
--- /dev/null
+++ b/components/livetv/LiveTVGuideRow.tsx
@@ -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 ;
+ }
+
+ return (
+
+ {programsWithPositions?.map((p) => (
+
+
+ {(() => {
+ return (
+ screenWidth && scrollX > p.position
+ ? scrollX - p.position
+ : 0,
+ }}
+ className="px-4 self-start"
+ >
+
+ {p.Name}
+
+
+ );
+ })()}
+
+
+ ))}
+
+ );
+};
diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx
index 12dcba1d..edc88f66 100644
--- a/components/music/SongsListItem.tsx
+++ b/components/music/SongsListItem.tsx
@@ -4,6 +4,7 @@ import { usePlayback } from "@/providers/PlaybackProvider";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios from "@/utils/profiles/ios";
+import iosFmp4 from "@/utils/profiles/iosFmp4";
import { runtimeTicksToSeconds } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
@@ -90,7 +91,7 @@ export const SongsListItem: React.FC = ({
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
sessionData,
- deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
+ deviceProfile: castDevice?.deviceId ? chromecastProfile : iosFmp4,
mediaSourceId: item.Id,
});
diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx
index 43bd6780..e03d590d 100644
--- a/components/series/SeasonEpisodesCarousel.tsx
+++ b/components/series/SeasonEpisodesCarousel.tsx
@@ -85,7 +85,7 @@ export const SeasonEpisodesCarousel: React.FC = ({
userId: user?.Id,
itemId: previousId,
}),
- staleTime: 60 * 1000,
+ staleTime: 60 * 1000 * 5,
});
}
@@ -101,7 +101,7 @@ export const SeasonEpisodesCarousel: React.FC = ({
userId: user?.Id,
itemId: nextId,
}),
- staleTime: 60 * 1000,
+ staleTime: 60 * 1000 * 5,
});
}
}, [episodes, api, user?.Id, item]);
diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx
index d67f224a..12409ab0 100644
--- a/components/stacks/NestedTabPageStack.tsx
+++ b/components/stacks/NestedTabPageStack.tsx
@@ -1,5 +1,3 @@
-import { Stack } from "expo-router";
-import { Chromecast } from "../Chromecast";
import { HeaderBackButton } from "../common/HeaderBackButton";
const commonScreenOptions = {
diff --git a/constants/Colors.ts b/constants/Colors.ts
index a269318f..82e999a7 100644
--- a/constants/Colors.ts
+++ b/constants/Colors.ts
@@ -7,6 +7,7 @@ const tintColorLight = "#0a7ea4";
const tintColorDark = "#fff";
export const Colors = {
+ primary: "#9334E9",
text: "#ECEDEE",
background: "#151718",
tint: tintColorDark,
diff --git a/constants/Languages.ts b/constants/Languages.ts
index 9832e03d..0a6d63b1 100644
--- a/constants/Languages.ts
+++ b/constants/Languages.ts
@@ -2,38 +2,38 @@ import { DefaultLanguageOption } from "@/utils/atoms/settings";
export const LANGUAGES: DefaultLanguageOption[] = [
{ label: "English", value: "eng" },
- { label: "Spanish", value: "es" },
- { label: "Chinese (Mandarin)", value: "zh" },
- { label: "Hindi", value: "hi" },
- { label: "Arabic", value: "ar" },
- { label: "French", value: "fr" },
- { label: "Russian", value: "ru" },
- { label: "Portuguese", value: "pt" },
- { label: "Japanese", value: "ja" },
- { label: "German", value: "de" },
- { label: "Italian", value: "it" },
- { label: "Korean", value: "ko" },
- { label: "Turkish", value: "tr" },
- { label: "Dutch", value: "nl" },
- { label: "Polish", value: "pl" },
- { label: "Vietnamese", value: "vi" },
- { label: "Thai", value: "th" },
- { label: "Indonesian", value: "id" },
- { label: "Greek", value: "el" },
- { label: "Swedish", value: "sv" },
- { label: "Danish", value: "da" },
- { label: "Norwegian", value: "no" },
- { label: "Finnish", value: "fi" },
- { label: "Czech", value: "cs" },
- { label: "Hungarian", value: "hu" },
- { label: "Romanian", value: "ro" },
- { label: "Ukrainian", value: "uk" },
- { label: "Hebrew", value: "he" },
- { label: "Bengali", value: "bn" },
- { label: "Punjabi", value: "pa" },
- { label: "Tagalog", value: "tl" },
- { label: "Swahili", value: "sw" },
- { label: "Malay", value: "ms" },
- { label: "Persian", value: "fa" },
- { label: "Urdu", value: "ur" },
+ { label: "Spanish", value: "spa" },
+ { label: "Chinese (Mandarin)", value: "cmn" },
+ { label: "Hindi", value: "hin" },
+ { label: "Arabic", value: "ara" },
+ { label: "French", value: "fra" },
+ { label: "Russian", value: "rus" },
+ { label: "Portuguese", value: "por" },
+ { label: "Japanese", value: "jpn" },
+ { label: "German", value: "deu" },
+ { label: "Italian", value: "ita" },
+ { label: "Korean", value: "kor" },
+ { label: "Turkish", value: "tur" },
+ { label: "Dutch", value: "nld" },
+ { label: "Polish", value: "pol" },
+ { label: "Vietnamese", value: "vie" },
+ { label: "Thai", value: "tha" },
+ { label: "Indonesian", value: "ind" },
+ { label: "Greek", value: "ell" },
+ { label: "Swedish", value: "swe" },
+ { label: "Danish", value: "dan" },
+ { label: "Norwegian", value: "nor" },
+ { label: "Finnish", value: "fin" },
+ { label: "Czech", value: "ces" },
+ { label: "Hungarian", value: "hun" },
+ { label: "Romanian", value: "ron" },
+ { label: "Ukrainian", value: "ukr" },
+ { label: "Hebrew", value: "heb" },
+ { label: "Bengali", value: "ben" },
+ { label: "Punjabi", value: "pan" },
+ { label: "Tagalog", value: "tgl" },
+ { label: "Swahili", value: "swa" },
+ { label: "Malay", value: "msa" },
+ { label: "Persian", value: "fas" },
+ { label: "Urdu", value: "urd" },
];
diff --git a/eas.json b/eas.json
index 4c03966e..5583d89f 100644
--- a/eas.json
+++ b/eas.json
@@ -22,13 +22,13 @@
}
},
"production": {
- "channel": "0.16.0",
+ "channel": "0.17.0",
"android": {
"image": "latest"
}
},
"production-apk": {
- "channel": "0.16.0",
+ "channel": "0.17.0",
"android": {
"buildType": "apk",
"image": "latest"
diff --git a/hooks/useImageColors.ts b/hooks/useImageColors.ts
index 9d0a3264..0a7cf821 100644
--- a/hooks/useImageColors.ts
+++ b/hooks/useImageColors.ts
@@ -19,19 +19,30 @@ import { getColors } from "react-native-image-colors";
* @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 [, setPrimaryColor] = useAtom(itemThemeColorAtom);
const source = useMemo(() => {
- if (!api || !item) return;
- return getItemImage({
- item,
- api,
- variant: "Primary",
- quality: 80,
- width: 300,
- });
+ if (!api) return;
+ if (url) return { uri: url };
+ else if (item)
+ return getItemImage({
+ item,
+ api,
+ variant: "Primary",
+ quality: 80,
+ width: 300,
+ });
+ else return;
}, [api, item]);
useEffect(() => {
diff --git a/package.json b/package.json
index ea67341b..f134386a 100644
--- a/package.json
+++ b/package.json
@@ -18,12 +18,14 @@
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.3",
+ "@futurejj/react-native-visibility-sensor": "^1.3.4",
"@gorhom/bottom-sheet": "^4",
"@jellyfin/sdk": "^0.10.0",
"@kesha-antonov/react-native-background-downloader": "^3.2.1",
"@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 +58,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,14 +75,16 @@
"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.3",
+ "react-native-video": "^6.6.4",
"react-native-web": "~0.19.10",
"sonner-native": "^0.14.2",
"tailwindcss": "3.3.2",
diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx
index bcd0fb07..b4c82b19 100644
--- a/providers/JellyfinProvider.tsx
+++ b/providers/JellyfinProvider.tsx
@@ -52,7 +52,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
- clientInfo: { name: "Streamyfin", version: "0.16.0" },
+ clientInfo: { name: "Streamyfin", version: "0.17.0" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
})
);
@@ -86,7 +86,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
- }, DeviceId="${deviceId}", Version="0.16.0"`,
+ }, DeviceId="${deviceId}", Version="0.17.0"`,
};
}, [deviceId]);
diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx
index f3569a03..37286540 100644
--- a/providers/PlaybackProvider.tsx
+++ b/providers/PlaybackProvider.tsx
@@ -128,10 +128,17 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
return;
}
- const res = await getMediaInfoApi(api!).getPlaybackInfo({
- itemId: state.item.Id,
- userId: user.Id,
- });
+ // Support live tv
+ const res =
+ 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({
api,
diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts
index df13af1f..b8b802e0 100644
--- a/utils/jellyfin/media/getStreamUrl.ts
+++ b/utils/jellyfin/media/getStreamUrl.ts
@@ -6,6 +6,10 @@ import {
PlaybackInfoResponse,
} 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";
+import { isPlainObject } from "lodash";
+import { Alert } from "react-native";
export const getStreamUrl = async ({
api,
@@ -14,11 +18,10 @@ export const getStreamUrl = async ({
startTimeTicks = 0,
maxStreamingBitrate,
sessionData,
- deviceProfile = ios,
+ deviceProfile = iosFmp4,
audioStreamIndex = 0,
subtitleStreamIndex = undefined,
forceDirectPlay = false,
- height,
mediaSourceId,
}: {
api: Api | null | undefined;
@@ -26,24 +29,55 @@ 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;
}
+ 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;
- /**
- * Build the stream URL for videos
- */
- const response = await api.axiosInstance.post(
+ const res2 = await api.axiosInstance.post(
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
{
DeviceProfile: deviceProfile,
@@ -66,23 +100,13 @@ export const getStreamUrl = async ({
}
);
- const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
+ mediaSource = res2.data.MediaSources.find(
(source: MediaSourceInfo) => source.Id === mediaSourceId
);
- if (!mediaSource) {
- 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 (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,
@@ -94,7 +118,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",
@@ -103,18 +127,11 @@ export const getStreamUrl = async ({
api.basePath
}/Audio/${itemId}/universal?${searchParams.toString()}`;
}
- } else if (mediaSource.TranscodingUrl) {
+ } else if (mediaSource?.TranscodingUrl) {
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
}
if (!url) throw new Error("No url");
- console.log(
- mediaSource.VideoType,
- mediaSource.Container,
- mediaSource.TranscodingContainer,
- mediaSource.TranscodingSubProtocol
- );
-
return url;
};
diff --git a/utils/jellyfin/session/capabilities.ts b/utils/jellyfin/session/capabilities.ts
index b7a3e795..0d26b813 100644
--- a/utils/jellyfin/session/capabilities.ts
+++ b/utils/jellyfin/session/capabilities.ts
@@ -6,6 +6,7 @@ import { Api } from "@jellyfin/sdk";
import { AxiosError, AxiosResponse } from "axios";
import { useMemo } from "react";
import { getAuthHeaders } from "../jellyfin";
+import iosFmp4 from "@/utils/profiles/iosFmp4";
interface PostCapabilitiesParams {
api: Api | null | undefined;
@@ -30,7 +31,7 @@ export const postCapabilities = async ({
throw new Error("Missing parameters for marking item as not played");
}
- let profile: any = ios;
+ let profile: any = iosFmp4;
if (deviceProfile === "Native") {
profile = native;