diff --git a/README.md b/README.md
index 297c962a..c7fc551e 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# 📺 Streamyfin
+
+
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
@@ -141,10 +143,6 @@ If you have questions or need support, feel free to reach out:
- GitHub Issues: Report bugs or request features here.
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
-## Support
-
-
-
## 📝 Credits
Streamyfin is developed by Fredrik Burmester and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
diff --git a/app.json b/app.json
index 5d8d431c..d4e5e08f 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
- "version": "0.8.2",
+ "version": "0.10.3",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -33,7 +33,7 @@
},
"android": {
"jsEngine": "hermes",
- "versionCode": 23,
+ "versionCode": 32,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
},
@@ -85,6 +85,11 @@
"deploymentTarget": "14.0"
},
"android": {
+ "android": {
+ "compileSdkVersion": 34,
+ "targetSdkVersion": 34,
+ "buildToolsVersion": "34.0.0"
+ },
"minSdkVersion": 24,
"usesCleartextTraffic": true,
"packagingOptions": {
diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx
index c187c68b..706e3581 100644
--- a/app/(auth)/(tabs)/(home)/downloads.tsx
+++ b/app/(auth)/(tabs)/(home)/downloads.tsx
@@ -8,11 +8,12 @@ import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery } from "@tanstack/react-query";
-import { router, Stack } from "expo-router";
+import { router } from "expo-router";
import { FFmpegKit } from "ffmpeg-kit-react-native";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
const downloads: React.FC = () => {
const [process, setProcess] = useAtom(runningProcesses);
@@ -53,6 +54,8 @@ const downloads: React.FC = () => {
return formatNumber(timeLeft / 10000);
}, [process]);
+ const insets = useSafeAreaInsets();
+
if (isLoading) {
return (
@@ -62,7 +65,13 @@ const downloads: React.FC = () => {
}
return (
-
+
@@ -70,7 +79,9 @@ const downloads: React.FC = () => {
{queue.map((q) => (
router.push(`/(auth)/items/${q.item.Id}`)}
+ onPress={() =>
+ router.push(`/(auth)/items/page?id=${q.item.Id}`)
+ }
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
@@ -97,7 +108,9 @@ const downloads: React.FC = () => {
Active download
{process?.item ? (
router.push(`/(auth)/items/${process.item.Id}`)}
+ onPress={() =>
+ router.push(`/(auth)/items/page?id=${process.item.Id}`)
+ }
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx
index b894a9d8..72688d26 100644
--- a/app/(auth)/(tabs)/(home)/index.tsx
+++ b/app/(auth)/(tabs)/(home)/index.tsx
@@ -1,4 +1,3 @@
-import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
@@ -6,7 +5,6 @@ import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
-import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
@@ -20,10 +18,28 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
-import { RefreshControl, ScrollView, View } from "react-native";
+import { RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+type BaseSection = {
+ title: string;
+ queryKey: (string | undefined)[];
+};
+
+type ScrollingCollectionListSection = BaseSection & {
+ type: "ScrollingCollectionList";
+ queryFn: () => Promise;
+ orientation?: "horizontal" | "vertical";
+};
+
+type MediaListSection = BaseSection & {
+ type: "MediaListSection";
+ queryFn: () => Promise;
+};
+
+type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() {
- const router = useRouter();
const queryClient = useQueryClient();
const [api] = useAtom(apiAtom);
@@ -50,42 +66,12 @@ export default function index() {
};
}, []);
- const { data, isLoading, isError } = useQuery({
- queryKey: ["resumeItems", user?.Id],
- queryFn: async () =>
- (api &&
- (
- await getItemsApi(api).getResumeItems({
- userId: user?.Id,
- })
- ).data.Items) ||
- [],
- enabled: !!api && !!user?.Id,
- staleTime: 60 * 1000,
- });
-
- const { data: _nextUpData, isLoading: isLoadingNextUp } = useQuery({
- queryKey: ["nextUp-all", user?.Id],
- queryFn: async () =>
- (api &&
- (
- await getTvShowsApi(api).getNextUp({
- userId: user?.Id,
- fields: ["MediaSourceCount"],
- limit: 20,
- })
- ).data.Items) ||
- [],
- enabled: !!api && !!user?.Id,
- staleTime: 0,
- });
-
- const nextUpData = useMemo(() => {
- return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id));
- }, [_nextUpData]);
-
- const { data: collections } = useQuery({
- queryKey: ["collectinos", user?.Id],
+ const {
+ data: userViews,
+ isError: e1,
+ isLoading: l1,
+ } = useQuery({
+ queryKey: ["userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
@@ -101,77 +87,11 @@ export default function index() {
staleTime: 60 * 1000,
});
- const movieCollectionId = useMemo(() => {
- return collections?.find((c) => c.CollectionType === "movies")?.Id;
- }, [collections]);
-
- const tvShowCollectionId = useMemo(() => {
- return collections?.find((c) => c.CollectionType === "tvshows")?.Id;
- }, [collections]);
-
const {
- data: recentlyAddedInMovies,
- isLoading: isLoadingRecentlyAddedMovies,
- } = useQuery({
- queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
- queryFn: async () =>
- (api &&
- (
- await getUserLibraryApi(api).getLatestMedia({
- userId: user?.Id,
- limit: 50,
- fields: ["PrimaryImageAspectRatio", "Path"],
- imageTypeLimit: 1,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
- parentId: movieCollectionId,
- })
- ).data) ||
- [],
- enabled: !!api && !!user?.Id && !!movieCollectionId,
- staleTime: 60 * 1000,
- });
-
- const {
- data: recentlyAddedInTVShows,
- isLoading: isLoadingRecentlyAddedTVShows,
- } = useQuery({
- queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId],
- queryFn: async () =>
- (api &&
- (
- await getUserLibraryApi(api).getLatestMedia({
- userId: user?.Id,
- limit: 50,
- fields: ["PrimaryImageAspectRatio", "Path"],
- imageTypeLimit: 1,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
- parentId: tvShowCollectionId,
- })
- ).data) ||
- [],
- enabled: !!api && !!user?.Id && !!tvShowCollectionId,
- staleTime: 60 * 1000,
- });
-
- const { data: suggestions, isLoading: isLoadingSuggestions } = useQuery<
- BaseItemDto[]
- >({
- queryKey: ["suggestions", user?.Id],
- queryFn: async () =>
- (api &&
- (
- await getSuggestionsApi(api).getSuggestions({
- userId: user?.Id,
- limit: 5,
- mediaType: ["Video"],
- })
- ).data.Items) ||
- [],
- enabled: !!api && !!user?.Id,
- staleTime: 60 * 1000,
- });
-
- const { data: mediaListCollections } = useQuery({
+ data: mediaListCollections,
+ isError: e2,
+ isLoading: l2,
+ } = useQuery({
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
@@ -187,11 +107,20 @@ export default function index() {
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
- staleTime: 0,
+ staleTime: 60 * 1000,
});
+ const movieCollectionId = useMemo(() => {
+ return userViews?.find((c) => c.CollectionType === "movies")?.Id;
+ }, [userViews]);
+
+ const tvShowCollectionId = useMemo(() => {
+ return userViews?.find((c) => c.CollectionType === "tvshows")?.Id;
+ }, [userViews]);
+
const refetch = useCallback(async () => {
setLoading(true);
+ await queryClient.refetchQueries({ queryKey: ["userViews"] });
await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
@@ -206,30 +135,145 @@ export default function index() {
setLoading(false);
}, [queryClient, user?.Id]);
- if (isConnected === false) {
- return (
-
- No Internet
-
- No worries, you can still watch{"\n"}downloaded content.
-
-
- router.push("/(auth)/downloads")}
- justify="center"
- iconRight={
-
- }
- >
- Go to downloads
-
-
-
- );
- }
+ const sections = useMemo(() => {
+ if (!api || !user?.Id) return [];
- if (isError)
+ const ss: Section[] = [
+ {
+ title: "Continue Watching",
+ queryKey: ["resumeItems", user.Id],
+ queryFn: async () =>
+ (
+ await getItemsApi(api).getResumeItems({
+ userId: user.Id,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ })
+ ).data.Items || [],
+ type: "ScrollingCollectionList",
+ orientation: "horizontal",
+ },
+ {
+ title: "Next Up",
+ queryKey: ["nextUp-all", user?.Id],
+ queryFn: async () =>
+ (
+ await getTvShowsApi(api).getNextUp({
+ userId: user?.Id,
+ fields: ["MediaSourceCount"],
+ limit: 20,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ })
+ ).data.Items || [],
+ type: "ScrollingCollectionList",
+ orientation: "horizontal",
+ },
+ ...(mediaListCollections?.map(
+ (ml) =>
+ ({
+ title: ml.Name || "",
+ queryKey: ["mediaList", ml.Id],
+ queryFn: async () => ml,
+ type: "MediaListSection",
+ } as MediaListSection)
+ ) || []),
+ {
+ title: "Recently Added in Movies",
+ queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
+ queryFn: async () =>
+ (
+ await getUserLibraryApi(api).getLatestMedia({
+ userId: user?.Id,
+ limit: 50,
+ fields: ["PrimaryImageAspectRatio", "Path"],
+ imageTypeLimit: 1,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ parentId: movieCollectionId,
+ })
+ ).data || [],
+ type: "ScrollingCollectionList",
+ },
+ {
+ title: "Recently Added in TV-Shows",
+ queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId],
+ queryFn: async () =>
+ (
+ await getUserLibraryApi(api).getLatestMedia({
+ userId: user?.Id,
+ limit: 50,
+ fields: ["PrimaryImageAspectRatio", "Path"],
+ imageTypeLimit: 1,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ parentId: tvShowCollectionId,
+ })
+ ).data || [],
+ type: "ScrollingCollectionList",
+ },
+ {
+ title: "Suggested Movies",
+ queryKey: ["suggestedMovies", user?.Id],
+ queryFn: async () =>
+ (
+ await getSuggestionsApi(api).getSuggestions({
+ userId: user?.Id,
+ limit: 10,
+ mediaType: ["Video"],
+ type: ["Movie"],
+ })
+ ).data.Items || [],
+ type: "ScrollingCollectionList",
+ orientation: "vertical",
+ },
+ {
+ title: "Suggested Episodes",
+ queryKey: ["suggestedEpisodes", user?.Id],
+ queryFn: async () =>
+ (
+ await getSuggestionsApi(api).getSuggestions({
+ userId: user?.Id,
+ limit: 10,
+ mediaType: ["Video"],
+ type: ["Episode"],
+ })
+ ).data.Items || [],
+ type: "ScrollingCollectionList",
+ orientation: "horizontal",
+ },
+ ];
+ return ss;
+ }, [
+ api,
+ user?.Id,
+ movieCollectionId,
+ tvShowCollectionId,
+ mediaListCollections,
+ ]);
+
+ // if (isConnected === false) {
+ // return (
+ //
+ // No Internet
+ //
+ // No worries, you can still watch{"\n"}downloaded content.
+ //
+ //
+ // router.push("/(auth)/downloads")}
+ // justify="center"
+ // iconRight={
+ //
+ // }
+ // >
+ // Go to downloads
+ //
+ //
+ //
+ // );
+ // }
+
+ const insets = useSafeAreaInsets();
+
+ if (e1 || e2)
return (
Oops!
@@ -239,7 +283,7 @@ export default function index() {
);
- if (isLoading)
+ if (l1 || l2)
return (
@@ -254,45 +298,37 @@ export default function index() {
}
>
-
+
-
-
-
-
- {mediaListCollections?.map((ml) => (
-
- ))}
-
-
-
-
-
-
+ {sections.map((section, index) => {
+ if (section.type === "ScrollingCollectionList") {
+ return (
+
+ );
+ } else if (section.type === "MediaListSection") {
+ return (
+
+ );
+ }
+ return null;
+ })}
);
diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx
index ce8d5df5..c688f9b2 100644
--- a/app/(auth)/(tabs)/(home)/settings.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tsx
@@ -9,6 +9,7 @@ import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import { ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function settings() {
const { logout } = useJellyfin();
@@ -23,9 +24,17 @@ export default function settings() {
refetchInterval: 1000,
});
+ const insets = useSafeAreaInsets();
+
return (
-
-
+
+
Information
diff --git a/app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx
index 3e17610d..565f84c8 100644
--- a/app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx
@@ -1,7 +1,9 @@
import { Chromecast } from "@/components/Chromecast";
+import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { SongsList } from "@/components/music/SongsList";
+import { ParallaxScrollView } from "@/components/ParallaxPage";
import ArtistPoster from "@/components/posters/ArtistPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
@@ -11,6 +13,7 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const searchParams = useLocalSearchParams();
@@ -88,30 +91,31 @@ export default function page() {
enabled: !!api && !!user?.Id,
});
+ const insets = useSafeAreaInsets();
+
if (!album) return null;
return (
-
-
-
-
-
-
-
- {album?.Name}
- {album?.ProductionYear}
-
-
- {album.AlbumArtists?.map((a) => (
-
-
- {album?.AlbumArtist}
-
-
- ))}
-
-
-
+
+ }
+ >
+
+ {album?.Name}
+
+ {songs?.TotalRecordCount} songs
+
+
+
-
+
);
}
diff --git a/app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx
index 4a60fb06..8d82d205 100644
--- a/app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx
@@ -8,6 +8,10 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { ItemImage } from "@/components/common/ItemImage";
+import { ParallaxScrollView } from "@/components/ParallaxPage";
+import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
export default function page() {
const searchParams = useLocalSearchParams();
@@ -82,50 +86,45 @@ export default function page() {
enabled: !!api && !!user?.Id,
});
- useEffect(() => {
- navigation.setOptions({
- title: albums?.Items?.[0]?.AlbumArtist || "",
- });
- }, [albums]);
+ const insets = useSafeAreaInsets();
if (!artist || !albums) return null;
return (
-
-
-
-
- Albums
-
- }
- nestedScrollEnabled
- data={albums.Items}
- numColumns={3}
- columnWrapperStyle={{
- justifyContent: "space-between",
- }}
- renderItem={({ item, index }) => (
- {
- router.push(`/albums/${item.Id}`);
+
-
-
- {item.Name}
- {item.ProductionYear}
-
-
- )}
- keyExtractor={(item) => item.Id || ""}
- />
+ />
+ }
+ >
+
+ {artist?.Name}
+
+ {albums.TotalRecordCount} albums
+
+
+
+ {albums.Items.map((item, idx) => (
+
+
+
+ {item.Name}
+ {item.ProductionYear}
+
+
+ ))}
+
+
);
}
diff --git a/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx
deleted file mode 100644
index 84958a90..00000000
--- a/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx
+++ /dev/null
@@ -1,246 +0,0 @@
-import { AudioTrackSelector } from "@/components/AudioTrackSelector";
-import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
-import { DownloadItem } from "@/components/DownloadItem";
-import { Loader } from "@/components/Loader";
-import { OverviewText } from "@/components/OverviewText";
-import { ParallaxScrollView } from "@/components/ParallaxPage";
-import { PlayButton } from "@/components/PlayButton";
-import { PlayedStatus } from "@/components/PlayedStatus";
-import { Ratings } from "@/components/Ratings";
-import { SimilarItems } from "@/components/SimilarItems";
-import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
-import { Text } from "@/components/common/Text";
-import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
-import { CastAndCrew } from "@/components/series/CastAndCrew";
-import { CurrentSeries } from "@/components/series/CurrentSeries";
-import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
-import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { useSettings } from "@/utils/atoms/settings";
-import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
-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 native from "@/utils/profiles/native";
-import old from "@/utils/profiles/old";
-import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
-import { Image } from "expo-image";
-import { useLocalSearchParams } from "expo-router";
-import { useAtom } from "jotai";
-import { useMemo, useState } from "react";
-import { View } from "react-native";
-import { useCastDevice } from "react-native-google-cast";
-
-const page: React.FC = () => {
- const local = useLocalSearchParams();
- const { id } = local as { id: string };
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const [settings] = useSettings();
-
- const castDevice = useCastDevice();
-
- const [selectedAudioStream, setSelectedAudioStream] = useState(-1);
- const [selectedSubtitleStream, setSelectedSubtitleStream] =
- useState(0);
- const [maxBitrate, setMaxBitrate] = useState({
- key: "Max",
- value: undefined,
- });
-
- const { data: item, isLoading: l1 } = useQuery({
- queryKey: ["item", id],
- queryFn: async () =>
- await getUserItemData({
- api,
- userId: user?.Id,
- itemId: id,
- }),
- enabled: !!id && !!api,
- staleTime: 60,
- });
-
- 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,
- });
-
- return playbackData.data;
- },
- enabled: !!item?.Id && !!api && !!user?.Id,
- staleTime: 0,
- });
-
- const { data: playbackUrl } = useQuery({
- queryKey: [
- "playbackUrl",
- item?.Id,
- maxBitrate,
- castDevice,
- selectedAudioStream,
- selectedSubtitleStream,
- settings,
- ],
- queryFn: async () => {
- if (!api || !user?.Id || !sessionData) 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,
- });
-
- console.log("Transcode URL: ", url);
-
- return url;
- },
- enabled: !!sessionData && !!api && !!user?.Id && !!item?.Id,
- staleTime: 0,
- });
-
- const backdropUrl = useMemo(
- () =>
- getBackdropUrl({
- api,
- item,
- quality: 90,
- width: 1000,
- }),
- [item]
- );
-
- const logoUrl = useMemo(
- () => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
- [item]
- );
-
- if (l1)
- return (
-
-
-
- );
-
- if (!item?.Id || !backdropUrl) return null;
-
- return (
-
- }
- logo={
- <>
- {logoUrl ? (
-
- ) : null}
- >
- }
- >
-
-
- {item.Type === "Episode" ? (
-
- ) : (
- <>
-
- >
- )}
- {item?.ProductionYear}
-
-
-
-
- {playbackUrl ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
- setMaxBitrate(val)}
- selected={maxBitrate}
- />
-
-
-
-
-
-
-
-
-
-
-
-
- {item.Type === "Episode" && (
-
-
-
- )}
-
-
-
-
-
- );
-};
-
-export default page;
diff --git a/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx
new file mode 100644
index 00000000..fa358ddf
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx
@@ -0,0 +1,13 @@
+import { ItemContent } from "@/components/ItemContent";
+import { useLocalSearchParams } from "expo-router";
+import React, { useMemo } from "react";
+
+const Page: React.FC = () => {
+ const { id } = useLocalSearchParams() as { id: string };
+
+ const memoizedContent = useMemo(() => , [id]);
+
+ return memoizedContent;
+};
+
+export default React.memo(Page);
diff --git a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx
index c7c99103..73870886 100644
--- a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx
@@ -20,6 +20,8 @@ const page: React.FC = () => {
seasonIndex: string;
};
+ console.log("seasonIndex", seasonIndex);
+
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -59,6 +61,7 @@ const page: React.FC = () => {
return (
{
-
+
);
diff --git a/app/(auth)/(tabs)/(home,libraries,search)/songs/[songId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/songs/[songId].tsx
deleted file mode 100644
index 8a93d33d..00000000
--- a/app/(auth)/(tabs)/(home,libraries,search)/songs/[songId].tsx
+++ /dev/null
@@ -1,271 +0,0 @@
-import { AudioTrackSelector } from "@/components/AudioTrackSelector";
-import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
-import { Chromecast } from "@/components/Chromecast";
-import { Text } from "@/components/common/Text";
-import { DownloadItem } from "@/components/DownloadItem";
-import { Loader } from "@/components/Loader";
-import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
-import { ParallaxScrollView } from "@/components/ParallaxPage";
-import { PlayButton } from "@/components/PlayButton";
-import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
-import { SimilarItems } from "@/components/SimilarItems";
-import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { usePlayback } from "@/providers/PlaybackProvider";
-import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
-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 { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
-import { Image } from "expo-image";
-import { useLocalSearchParams, useNavigation } from "expo-router";
-import { useAtom } from "jotai";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { ScrollView, View } from "react-native";
-import CastContext, {
- PlayServicesState,
- useCastDevice,
- useRemoteMediaClient,
-} from "react-native-google-cast";
-
-const page: React.FC = () => {
- const local = useLocalSearchParams();
- const { songId: id } = local as { songId: string };
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const { setCurrentlyPlayingState } = usePlayback();
-
- const castDevice = useCastDevice();
- const navigation = useNavigation();
-
- useEffect(() => {
- navigation.setOptions({
- headerRight: () => (
-
-
-
- ),
- });
- });
-
- const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
- const [selectedAudioStream, setSelectedAudioStream] = useState(-1);
- const [selectedSubtitleStream, setSelectedSubtitleStream] =
- useState(0);
- const [maxBitrate, setMaxBitrate] = useState({
- key: "Max",
- value: undefined,
- });
-
- const { data: item, isLoading: l1 } = useQuery({
- queryKey: ["item", id],
- queryFn: async () =>
- await getUserItemData({
- api,
- userId: user?.Id,
- itemId: id,
- }),
- enabled: !!id && !!api,
- staleTime: 60 * 1000,
- });
-
- const backdropUrl = useMemo(
- () =>
- getBackdropUrl({
- api,
- item,
- quality: 90,
- width: 1000,
- }),
- [item]
- );
-
- const logoUrl = useMemo(
- () => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
- [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,
- });
-
- return playbackData.data;
- },
- enabled: !!item?.Id && !!api && !!user?.Id,
- staleTime: 0,
- });
-
- const { data: playbackUrl } = useQuery({
- queryKey: [
- "playbackUrl",
- item?.Id,
- maxBitrate,
- castDevice,
- selectedAudioStream,
- selectedSubtitleStream,
- ],
- queryFn: async () => {
- if (!api || !user?.Id || !sessionData) return null;
-
- const url = await getStreamUrl({
- api,
- userId: user.Id,
- item,
- startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
- maxStreamingBitrate: maxBitrate.value,
- sessionData,
- deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
- audioStreamIndex: selectedAudioStream,
- subtitleStreamIndex: selectedSubtitleStream,
- });
-
- console.log("Transcode URL: ", url);
-
- return url;
- },
- enabled: !!sessionData,
- staleTime: 0,
- });
-
- const client = useRemoteMediaClient();
-
- const onPressPlay = useCallback(
- async (type: "device" | "cast" = "device") => {
- if (!playbackUrl || !item) return;
-
- if (type === "cast" && client) {
- await CastContext.getPlayServicesState().then((state) => {
- if (state && state !== PlayServicesState.SUCCESS)
- CastContext.showPlayServicesErrorDialog(state);
- else {
- client.loadMedia({
- mediaInfo: {
- contentUrl: playbackUrl,
- contentType: "video/mp4",
- metadata: {
- type: item.Type === "Episode" ? "tvShow" : "movie",
- title: item.Name || "",
- subtitle: item.Overview || "",
- },
- },
- startTime: 0,
- });
- }
- });
- } else {
- setCurrentlyPlayingState({
- item,
- url: playbackUrl,
- });
- }
- },
- [playbackUrl, item]
- );
-
- if (l1)
- return (
-
-
-
- );
-
- if (!item?.Id || !backdropUrl) return null;
-
- return (
-
- }
- logo={
- <>
- {logoUrl ? (
-
- ) : null}
- >
- }
- >
-
-
-
- {item?.ProductionYear}
-
-
-
- {playbackUrl ? (
-
- ) : (
-
- )}
-
-
-
-
- setMaxBitrate(val)}
- selected={maxBitrate}
- />
-
-
-
-
-
-
-
-
-
-
-
-
- Audio
-
-
-
- {item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default page;
diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
index 3f96282e..77b2e3ff 100644
--- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
+++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
@@ -9,7 +9,12 @@ import React, {
useMemo,
useState,
} from "react";
-import { FlatList, RefreshControl, View } from "react-native";
+import {
+ FlatList,
+ RefreshControl,
+ useWindowDimensions,
+ View,
+} from "react-native";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
@@ -39,6 +44,8 @@ import {
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { Loader } from "@/components/Loader";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { orientationAtom } from "@/utils/atoms/orientation";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
@@ -49,6 +56,7 @@ const Page = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
+ const { width: screenWidth } = useWindowDimensions();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
@@ -56,9 +64,15 @@ const Page = () => {
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
- const [orientation, setOrientation] = useState(
- ScreenOrientation.Orientation.PORTRAIT_UP
- );
+ const [orientation, setOrientation] = useAtom(orientationAtom);
+
+ const getNumberOfColumns = useCallback(() => {
+ if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
+ if (screenWidth < 600) return 5;
+ if (screenWidth < 960) return 6;
+ if (screenWidth < 1280) return 7;
+ return 6;
+ }, [screenWidth, orientation]);
useLayoutEffect(() => {
setSortBy([
@@ -75,22 +89,6 @@ const Page = () => {
]);
}, []);
- useEffect(() => {
- const subscription = ScreenOrientation.addOrientationChangeListener(
- (event) => {
- setOrientation(event.orientationInfo.orientation);
- }
- );
-
- ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
- setOrientation(initialOrientation);
- });
-
- return () => {
- ScreenOrientation.removeOrientationChangeListener(subscription);
- };
- }, []);
-
const { data: library, isLoading: isLibraryLoading } = useQuery({
queryKey: ["library", libraryId],
queryFn: async () => {
@@ -193,18 +191,19 @@ const Page = () => {
key={item.Id}
style={{
width: "100%",
- marginBottom:
- orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
+ marginBottom: 4,
}}
item={item}
>
{
]
);
+ const insets = useSafeAreaInsets();
+
if (isLoading || isLibraryLoading)
return (
@@ -399,11 +400,10 @@ const Page = () => {
contentInsetAdjustmentBehavior="automatic"
data={flatData}
renderItem={renderItem}
+ extraData={orientation}
keyExtractor={keyExtractor}
estimatedItemSize={244}
- numColumns={
- orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
- }
+ numColumns={getNumberOfColumns()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
@@ -411,7 +411,11 @@ const Page = () => {
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
- contentContainerStyle={{ paddingBottom: 24 }}
+ contentContainerStyle={{
+ paddingBottom: 24,
+ paddingLeft: insets.left,
+ paddingRight: insets.right,
+ }}
ItemSeparatorComponent={() => (
{
@@ -54,6 +55,8 @@ export default function index() {
}
}, [data]);
+ const insets = useSafeAreaInsets();
+
if (isLoading)
return (
@@ -76,6 +79,8 @@ export default function index() {
paddingTop: 17,
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
paddingBottom: 150,
+ paddingLeft: insets.left,
+ paddingRight: insets.right,
}}
data={data}
renderItem={({ item }) => }
diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx
index 9fadddbb..5975c9e6 100644
--- a/app/(auth)/(tabs)/(search)/index.tsx
+++ b/app/(auth)/(tabs)/(search)/index.tsx
@@ -1,4 +1,3 @@
-import { Button } from "@/components/Button";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
@@ -12,8 +11,6 @@ import SeriesPoster from "@/components/posters/SeriesPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
-import { Ionicons } from "@expo/vector-icons";
-import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemKind,
@@ -21,13 +18,7 @@ import {
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
-import {
- Href,
- router,
- useLocalSearchParams,
- useNavigation,
- usePathname,
-} from "expo-router";
+import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
@@ -37,6 +28,7 @@ import React, {
useState,
} from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
const exampleSearches = [
@@ -50,6 +42,7 @@ const exampleSearches = [
export default function search() {
const params = useLocalSearchParams();
+ const insets = useSafeAreaInsets();
const { q, prev } = params as { q: string; prev: Href };
@@ -229,6 +222,10 @@ export default function search() {
{Platform.OS === "android" && (
@@ -254,7 +251,7 @@ export default function search() {
header="Movies"
ids={movies?.map((m) => m.Id!)}
renderItem={(data) => (
-
+ (
m.Id!)}
header="Series"
renderItem={(data) => (
-
+ (
m.Id!)}
header="Episodes"
renderItem={(data) => (
-
+ (
router.push(`/items/${item.Id}`)}
+ onPress={() => router.push(`/items/page?id=${item.Id}`)}
className="flex flex-col w-44"
>
@@ -321,7 +318,7 @@ export default function search() {
ids={collections?.map((m) => m.Id!)}
header="Collections"
renderItem={(data) => (
-
+ (
m.Id!)}
header="Actors"
renderItem={(data) => (
-
+ (
m.Id!)}
header="Artists"
renderItem={(data) => (
-
+ (
m.Id!)}
header="Albums"
renderItem={(data) => (
-
+ (
m.Id!)}
header="Songs"
renderItem={(data) => (
-
+ (
{
+ const subscription = ScreenOrientation.addOrientationChangeListener(
+ (event) => {
+ console.log(event.orientationInfo.orientation);
+ setOrientation(event.orientationInfo.orientation);
+ }
+ );
+
+ ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
+ setOrientation(initialOrientation);
+ });
+
+ return () => {
+ ScreenOrientation.removeOrientationChangeListener(subscription);
+ };
+ }, []);
+
+ const url = Linking.useURL();
+
+ if (url) {
+ const { hostname, path, queryParams } = Linking.parse(url);
+ }
+
return (
diff --git a/app/login.tsx b/app/login.tsx
index b5639ffd..9cb37339 100644
--- a/app/login.tsx
+++ b/app/login.tsx
@@ -3,9 +3,9 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
-import { AxiosError } from "axios";
+import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
-import React, { useMemo, useState } from "react";
+import React, { useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
@@ -21,19 +21,44 @@ const CredentialsSchema = z.object({
});
const Login: React.FC = () => {
- const { setServer, login, removeServer } = useJellyfin();
+ const { setServer, login, removeServer, initiateQuickConnect } =
+ useJellyfin();
const [api] = useAtom(apiAtom);
+ const params = useLocalSearchParams();
- const [serverURL, setServerURL] = useState("");
+ const {
+ apiUrl: _apiUrl,
+ username: _username,
+ password: _password,
+ } = params as { apiUrl: string; username: string; password: string };
+
+ const [serverURL, setServerURL] = useState(_apiUrl);
const [error, setError] = useState("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
}>({
- username: "",
- password: "",
+ username: _username,
+ password: _password,
});
+ useEffect(() => {
+ (async () => {
+ if (_apiUrl) {
+ setServer({
+ address: _apiUrl,
+ });
+
+ setTimeout(() => {
+ if (_username && _password) {
+ setCredentials({ username: _username, password: _password });
+ login(_username, _password);
+ }
+ }, 300);
+ }
+ })();
+ }, [_apiUrl, _username, _password]);
+
const [loading, setLoading] = useState(false);
const handleLogin = async () => {
@@ -62,6 +87,21 @@ const Login: React.FC = () => {
setServer({ address: url.trim() });
};
+ const handleQuickConnect = async () => {
+ try {
+ const code = await initiateQuickConnect();
+ if (code) {
+ Alert.alert("Quick Connect", `Enter code ${code} to login`, [
+ {
+ text: "Got It",
+ },
+ ]);
+ }
+ } catch (error) {
+ Alert.alert("Error", "Failed to initiate Quick Connect");
+ }
+ };
+
if (api?.basePath) {
return (
@@ -137,13 +177,18 @@ const Login: React.FC = () => {
{error}
-
- Log in
-
+
+
+ Use Quick Connect
+
+
+ Log in
+
+
diff --git a/bun.lockb b/bun.lockb
index 32f4452c..20d73a62 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx
index d20e1184..5b3f91db 100644
--- a/components/AudioTrackSelector.tsx
+++ b/components/AudioTrackSelector.tsx
@@ -2,27 +2,29 @@ import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
interface Props extends React.ComponentProps {
- item: BaseItemDto;
+ source: MediaSourceInfo;
onChange: (value: number) => void;
selected: number;
}
export const AudioTrackSelector: React.FC = ({
- item,
+ source,
onChange,
selected,
...props
}) => {
const audioStreams = useMemo(
- () =>
- item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
- [item]
+ () => source.MediaStreams?.filter((x) => x.Type === "Audio"),
+ [source]
);
const selectedAudioSteam = useMemo(
@@ -31,23 +33,26 @@ export const AudioTrackSelector: React.FC = ({
);
useEffect(() => {
- const index = item.MediaSources?.[0].DefaultAudioStreamIndex;
+ const index = source.DefaultAudioStreamIndex;
if (index !== undefined && index !== null) onChange(index);
}, []);
return (
-
+
-
- Audio streams
-
-
-
- {tc(selectedAudioSteam?.DisplayTitle, 13)}
-
-
-
+
+ Audio
+
+
+ {selectedAudioSteam?.DisplayTitle}
+
+
{
onChange: (value: Bitrate) => void;
selected: Bitrate;
+ inverted?: boolean;
}
export const BitrateSelector: React.FC = ({
onChange,
selected,
+ inverted,
...props
}) => {
+ const sorted = useMemo(() => {
+ if (inverted)
+ return BITRATES.sort(
+ (a, b) => (a.value || Infinity) - (b.value || Infinity)
+ );
+ return BITRATES.sort(
+ (a, b) => (b.value || Infinity) - (a.value || Infinity)
+ );
+ }, []);
+
return (
-
+
-
- Bitrate
-
-
-
- {BITRATES.find((b) => b.value === selected.value)?.key}
-
-
-
+
+ Quality
+
+
+ {BITRATES.find((b) => b.value === selected.value)?.key}
+
+
Bitrates
- {BITRATES?.map((b, index: number) => (
+ {sorted.map((b) => (
{
onChange(b);
}}
diff --git a/components/Button.tsx b/components/Button.tsx
index 813c4222..305312d4 100644
--- a/components/Button.tsx
+++ b/components/Button.tsx
@@ -10,7 +10,7 @@ interface ButtonProps extends React.ComponentProps {
disabled?: boolean;
children?: string | ReactNode;
loading?: boolean;
- color?: "purple" | "red" | "black";
+ color?: "purple" | "red" | "black" | "transparent";
iconRight?: ReactNode;
iconLeft?: ReactNode;
justify?: "center" | "between";
@@ -37,6 +37,8 @@ export const Button: React.FC> = ({
return "bg-red-600";
case "black":
return "bg-neutral-900 border border-neutral-800";
+ case "transparent":
+ return "bg-transparent";
}
}, [color]);
diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx
index 6539ce0b..0b8c9cce 100644
--- a/components/Chromecast.tsx
+++ b/components/Chromecast.tsx
@@ -1,6 +1,6 @@
import { BlurView } from "expo-blur";
import React, { useEffect } from "react";
-import { View } from "react-native";
+import { Platform, View, ViewProps } from "react-native";
import GoogleCast, {
CastButton,
useCastDevice,
@@ -8,16 +8,17 @@ import GoogleCast, {
useRemoteMediaClient,
} from "react-native-google-cast";
-type Props = {
+interface Props extends ViewProps {
width?: number;
height?: number;
background?: "blur" | "transparent";
-};
+}
export const Chromecast: React.FC = ({
width = 48,
height = 48,
background = "transparent",
+ ...props
}) => {
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
@@ -37,7 +38,20 @@ export const Chromecast: React.FC = ({
if (background === "transparent")
return (
-
+
+
+
+ );
+
+ if (Platform.OS === "android")
+ return (
+
);
@@ -46,6 +60,7 @@ export const Chromecast: React.FC = ({
diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx
index e057ee15..c93ec275 100644
--- a/components/ContinueWatchingPoster.tsx
+++ b/components/ContinueWatchingPoster.tsx
@@ -5,29 +5,41 @@ import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
-import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
type ContinueWatchingPosterProps = {
item: BaseItemDto;
width?: number;
+ useEpisodePoster?: boolean;
};
const ContinueWatchingPoster: React.FC = ({
item,
width = 176,
+ useEpisodePoster = false,
}) => {
const [api] = useAtom(apiAtom);
- const url = useMemo(
- () =>
- getPrimaryImageUrl({
- api,
- item,
- quality: 80,
- width: 300,
- }),
- [item]
- );
+ /**
+ * Get horrizontal poster for movie and episode, with failover to primary.
+ */
+ const url = useMemo(() => {
+ if (!api) return;
+ if (item.Type === "Episode" && useEpisodePoster) {
+ return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
+ }
+ if (item.Type === "Episode") {
+ if (item.ParentBackdropItemId && item.ParentThumbImageTag)
+ return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
+ else
+ return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
+ }
+ if (item.Type === "Movie") {
+ 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
diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx
index a3276692..92a938a7 100644
--- a/components/CurrentlyPlayingBar.tsx
+++ b/components/CurrentlyPlayingBar.tsx
@@ -24,8 +24,8 @@ export const CurrentlyPlayingBar: React.FC = () => {
currentlyPlaying,
pauseVideo,
playVideo,
- setCurrentlyPlayingState,
stopPlayback,
+ setVolume,
setIsPlaying,
isPlaying,
videoRef,
@@ -34,7 +34,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
} = usePlayback();
const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
const aBottom = useSharedValue(0);
const aPadding = useSharedValue(0);
@@ -64,6 +63,8 @@ export const CurrentlyPlayingBar: React.FC = () => {
};
});
+ const from = useMemo(() => segments[2], [segments]);
+
useEffect(() => {
if (segments.find((s) => s.includes("tabs"))) {
// Tab screen - i.e. home
@@ -92,16 +93,40 @@ export const CurrentlyPlayingBar: React.FC = () => {
[currentlyPlaying?.item]
);
- const backdropUrl = useMemo(
- () =>
- getBackdropUrl({
+ const poster = useMemo(() => {
+ if (currentlyPlaying?.item.Type === "Audio")
+ return `${api?.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`;
+ else
+ return getBackdropUrl({
api,
item: currentlyPlaying?.item,
quality: 70,
width: 200,
- }),
- [currentlyPlaying?.item, api]
- );
+ });
+ }, [currentlyPlaying?.item.Id, api]);
+
+ const videoSource = useMemo(() => {
+ if (!api || !currentlyPlaying || !poster) return null;
+ return {
+ uri: currentlyPlaying.url,
+ isNetwork: true,
+ startPosition,
+ headers: getAuthHeaders(api),
+ metadata: {
+ artist: currentlyPlaying.item?.AlbumArtist
+ ? currentlyPlaying.item?.AlbumArtist
+ : undefined,
+ title: currentlyPlaying.item?.Name || "Unknown",
+ description: currentlyPlaying.item?.Overview
+ ? currentlyPlaying.item?.Overview
+ : undefined,
+ imageUri: poster,
+ subtitle: currentlyPlaying.item?.Album
+ ? currentlyPlaying.item?.Album
+ : undefined,
+ },
+ };
+ }, [currentlyPlaying, startPosition, api, poster]);
if (!api || !currentlyPlaying) return null;
@@ -137,7 +162,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
}
`}
>
- {currentlyPlaying?.url && (
+ {videoSource && (
{
controls={false}
pictureInPicture={true}
poster={
- backdropUrl && currentlyPlaying.item?.Type === "Audio"
- ? backdropUrl
+ poster && currentlyPlaying.item?.Type === "Audio"
+ ? poster
: undefined
}
debug={{
enable: true,
thread: true,
}}
- paused={!isPlaying}
onProgress={(e) => onProgress(e)}
subtitleStyle={{
fontSize: 16,
}}
- source={{
- uri: currentlyPlaying.url,
- isNetwork: true,
- startPosition,
- headers: getAuthHeaders(api),
- metadata: {
- artist: currentlyPlaying.item?.AlbumArtist
- ? currentlyPlaying.item?.AlbumArtist
- : undefined,
- title: currentlyPlaying.item?.Name
- ? currentlyPlaying.item?.Name
- : "Unknown",
- description: currentlyPlaying.item?.Overview
- ? currentlyPlaying.item?.Overview
- : undefined,
- imageUri: backdropUrl ? backdropUrl : undefined,
- subtitle: currentlyPlaying.item?.Album
- ? currentlyPlaying.item?.Album
- : undefined,
- },
- }}
+ source={videoSource}
onRestoreUserInterfaceForPictureInPictureStop={() => {
setTimeout(() => {
presentFullscreenPlayer();
}, 300);
}}
- onBuffer={(e) =>
- e.isBuffering ? console.log("Buffering...") : null
- }
onFullscreenPlayerDidDismiss={() => {}}
onFullscreenPlayerDidPresent={() => {}}
onPlaybackStateChanged={(e) => {
- if (e.isPlaying) {
- setIsPlaying(true);
- } else if (e.isSeeking) {
- return;
- } else {
- setIsPlaying(false);
+ if (e.isPlaying === true) {
+ playVideo(false);
+ } else if (e.isPlaying === false) {
+ pauseVideo(false);
}
}}
- progressUpdateInterval={2000}
+ onVolumeChange={(e) => {
+ setVolume(e.volume);
+ }}
+ progressUpdateInterval={4000}
onError={(e) => {
console.log(e);
writeToLog(
@@ -226,9 +228,17 @@ export const CurrentlyPlayingBar: React.FC = () => {
{
- if (currentlyPlaying.item?.Type === "Audio")
- router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
- else router.push(`/items/${currentlyPlaying.item?.Id}`);
+ if (currentlyPlaying.item?.Type === "Audio") {
+ router.push(
+ // @ts-ignore
+ `/(auth)/(tabs)/${from}/albums/${currentlyPlaying.item.AlbumId}`
+ );
+ } else {
+ router.push(
+ // @ts-ignore
+ `/(auth)/(tabs)/${from}/items/page?id=${currentlyPlaying.item?.Id}`
+ );
+ }
}}
>
{currentlyPlaying.item?.Name}
@@ -237,7 +247,8 @@ export const CurrentlyPlayingBar: React.FC = () => {
{
router.push(
- `/(auth)/series/${currentlyPlaying.item.SeriesId}`
+ // @ts-ignore
+ `/(auth)/(tabs)/${from}/series/${currentlyPlaying.item.SeriesId}`
);
}}
className="text-xs opacity-50"
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index 5f83ad03..e74f800c 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -2,8 +2,17 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runningProcesses } from "@/utils/atoms/downloads";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
-import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
+import { useSettings } from "@/utils/atoms/settings";
+import ios from "@/utils/profiles/ios";
+import native from "@/utils/profiles/native";
+import old from "@/utils/profiles/old";
import Ionicons from "@expo/vector-icons/Ionicons";
+import {
+ BottomSheetBackdrop,
+ BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
import {
BaseItemDto,
MediaSourceInfo,
@@ -12,21 +21,18 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
-import {
- TouchableOpacity,
- TouchableOpacityProps,
- View,
- ViewProps,
-} from "react-native";
+import { useCallback, useMemo, useRef, useState } from "react";
+import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
+import { AudioTrackSelector } from "./AudioTrackSelector";
+import { Bitrate, BitrateSelector } from "./BitrateSelector";
+import { Button } from "./Button";
+import { Text } from "./common/Text";
import { Loader } from "./Loader";
+import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
-import { DownloadQuality, useSettings } from "@/utils/atoms/settings";
-import { useCallback } from "react";
-import ios from "@/utils/profiles/ios";
-import native from "@/utils/profiles/native";
-import old from "@/utils/profiles/old";
+import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
-interface DownloadProps extends TouchableOpacityProps {
+interface DownloadProps extends ViewProps {
item: BaseItemDto;
}
@@ -35,100 +41,135 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
-
const [settings] = useSettings();
-
const { startRemuxing } = useRemuxHlsToMp4(item);
- const initiateDownload = useCallback(
- async (qualitySetting: DownloadQuality) => {
- if (!api || !user?.Id || !item.Id) {
- throw new Error(
- "DownloadItem ~ initiateDownload: No api or user or item"
- );
- }
+ const [selectedMediaSource, setSelectedMediaSource] =
+ useState(null);
+ const [selectedAudioStream, setSelectedAudioStream] = useState(-1);
+ const [selectedSubtitleStream, setSelectedSubtitleStream] =
+ useState(0);
+ const [maxBitrate, setMaxBitrate] = useState({
+ key: "Max",
+ value: undefined,
+ });
- let deviceProfile: any = ios;
+ const userCanDownload = useMemo(() => {
+ return user?.Policy?.EnableContentDownloading;
+ }, [user]);
- if (settings?.deviceProfile === "Native") {
- deviceProfile = native;
- } else if (settings?.deviceProfile === "Old") {
- deviceProfile = old;
- }
+ /**
+ * Bottom sheet
+ */
+ const bottomSheetModalRef = useRef(null);
- let maxStreamingBitrate: number | undefined = undefined;
+ const handlePresentModalPress = useCallback(() => {
+ bottomSheetModalRef.current?.present();
+ }, []);
- if (qualitySetting === "high") {
- maxStreamingBitrate = 8000000;
- } else if (qualitySetting === "low") {
- maxStreamingBitrate = 2000000;
- }
+ const handleSheetChanges = useCallback((index: number) => {
+ console.log("handleSheetChanges", index);
+ }, []);
- const response = await api.axiosInstance.post(
- `${api.basePath}/Items/${item.Id}/PlaybackInfo`,
- {
- DeviceProfile: deviceProfile,
- UserId: user.Id,
- MaxStreamingBitrate: maxStreamingBitrate,
- StartTimeTicks: 0,
- EnableTranscoding: maxStreamingBitrate ? true : undefined,
- AutoOpenLiveStream: true,
- MediaSourceId: item.Id,
- AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
- },
- {
- headers: {
- Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
- },
- }
+ const closeModal = useCallback(() => {
+ bottomSheetModalRef.current?.dismiss();
+ }, []);
+
+ /**
+ * Start download
+ */
+ const initiateDownload = useCallback(async () => {
+ if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) {
+ throw new Error(
+ "DownloadItem ~ initiateDownload: No api or user or item"
);
+ }
- let url: string | undefined = undefined;
+ let deviceProfile: any = ios;
- const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
+ if (settings?.deviceProfile === "Native") {
+ deviceProfile = native;
+ } else if (settings?.deviceProfile === "Old") {
+ deviceProfile = old;
+ }
- if (!mediaSource) {
- throw new Error("No media source");
+ const response = await api.axiosInstance.post(
+ `${api.basePath}/Items/${item.Id}/PlaybackInfo`,
+ {
+ DeviceProfile: deviceProfile,
+ UserId: user.Id,
+ MaxStreamingBitrate: maxBitrate.value,
+ StartTimeTicks: 0,
+ EnableTranscoding: maxBitrate.value ? true : undefined,
+ AutoOpenLiveStream: true,
+ AllowVideoStreamCopy: maxBitrate.value ? false : true,
+ MediaSourceId: selectedMediaSource?.Id,
+ AudioStreamIndex: selectedAudioStream,
+ SubtitleStreamIndex: selectedSubtitleStream,
+ },
+ {
+ headers: {
+ Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
+ },
}
+ );
- if (mediaSource.SupportsDirectPlay) {
- if (item.MediaType === "Video") {
- console.log("Using direct stream for video!");
- url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true`;
- } else if (item.MediaType === "Audio") {
- console.log("Using direct stream for audio!");
- const searchParams = new URLSearchParams({
- UserId: user.Id,
- DeviceId: api.deviceInfo.id,
- MaxStreamingBitrate: "140000000",
- Container:
- "opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
- TranscodingContainer: "mp4",
- TranscodingProtocol: "hls",
- AudioCodec: "aac",
- api_key: api.accessToken,
- StartTimeTicks: "0",
- EnableRedirection: "true",
- EnableRemoteMedia: "false",
- });
- url = `${api.basePath}/Audio/${
- item.Id
- }/universal?${searchParams.toString()}`;
- }
+ let url: string | undefined = undefined;
+
+ const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
+ (source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
+ );
+
+ if (!mediaSource) {
+ throw new Error("No media source");
+ }
+
+ if (mediaSource.SupportsDirectPlay) {
+ if (item.MediaType === "Video") {
+ console.log("Using direct stream for video!");
+ url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
+ } else if (item.MediaType === "Audio") {
+ console.log("Using direct stream for audio!");
+ const searchParams = new URLSearchParams({
+ UserId: user.Id,
+ DeviceId: api.deviceInfo.id,
+ MaxStreamingBitrate: "140000000",
+ Container:
+ "opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
+ TranscodingContainer: "mp4",
+ TranscodingProtocol: "hls",
+ AudioCodec: "aac",
+ api_key: api.accessToken,
+ StartTimeTicks: "0",
+ EnableRedirection: "true",
+ EnableRemoteMedia: "false",
+ });
+ url = `${api.basePath}/Audio/${
+ item.Id
+ }/universal?${searchParams.toString()}`;
}
+ } else if (mediaSource.TranscodingUrl) {
+ console.log("Using transcoded stream!");
+ url = `${api.basePath}${mediaSource.TranscodingUrl}`;
+ }
- if (mediaSource.TranscodingUrl) {
- console.log("Using transcoded stream!");
- url = `${api.basePath}${mediaSource.TranscodingUrl}`;
- } else {
- throw new Error("No transcoding url");
- }
+ if (!url) throw new Error("No url");
- return await startRemuxing(url);
- },
- [api, item, startRemuxing, user?.Id]
- );
+ return await startRemuxing(url);
+ }, [
+ api,
+ item,
+ startRemuxing,
+ user?.Id,
+ selectedMediaSource,
+ selectedAudioStream,
+ selectedSubtitleStream,
+ maxBitrate,
+ ]);
+ /**
+ * Check if item is downloaded
+ */
const { data: downloaded, isFetching } = useQuery({
queryKey: ["downloaded", item.Id],
queryFn: async () => {
@@ -143,23 +184,30 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
enabled: !!item.Id,
});
- if (isFetching) {
- return (
-
-
-
- );
- }
-
- if (process && process?.item.Id === item.Id) {
- return (
- {
- router.push("/downloads");
- }}
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
-
+ disappearsOnIndex={-1}
+ appearsOnIndex={0}
+ />
+ ),
+ []
+ );
+
+ return (
+
+ {isFetching ? (
+
+ ) : process && process?.item.Id === item.Id ? (
+ {
+ router.push("/downloads");
+ }}
+ >
{process.progress === 0 ? (
) : (
@@ -173,61 +221,97 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
/>
)}
-
-
- );
- }
-
- if (queue.some((i) => i.id === item.Id)) {
- return (
- {
- router.push("/downloads");
- }}
- {...props}
- >
-
+
+ ) : queue.some((i) => i.id === item.Id) ? (
+ {
+ router.push("/downloads");
+ }}
+ >
-
-
- );
- }
-
- if (downloaded) {
- return (
- {
- router.push("/downloads");
- }}
- {...props}
- >
-
+
+ ) : downloaded ? (
+ {
+ router.push("/downloads");
+ }}
+ >
-
-
- );
- } else {
- return (
- {
- queueActions.enqueue(queue, setQueue, {
- id: item.Id!,
- execute: async () => {
- // await startRemuxing(playbackUrl);
- if (!settings?.downloadQuality?.value) {
- throw new Error("No download quality selected");
- }
- await initiateDownload(settings?.downloadQuality?.value);
- },
- item,
- });
+
+ ) : (
+
+
+
+ )}
+
-
-
-
-
- );
- }
+
+
+
+ Download options
+
+
+ setMaxBitrate(val)}
+ selected={maxBitrate}
+ />
+
+ {selectedMediaSource && (
+
+
+
+
+ )}
+
+ {
+ if (userCanDownload === true) {
+ closeModal();
+ queueActions.enqueue(queue, setQueue, {
+ id: item.Id!,
+ execute: async () => {
+ await initiateDownload();
+ },
+ item,
+ });
+ } else {
+ Alert.alert(
+ "Disabled",
+ "This user is not allowed to download files."
+ );
+ }
+ }}
+ color="purple"
+ >
+ Download
+
+
+
+
+
+ );
};
diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx
new file mode 100644
index 00000000..2722e012
--- /dev/null
+++ b/components/ItemContent.tsx
@@ -0,0 +1,364 @@
+import { AudioTrackSelector } from "@/components/AudioTrackSelector";
+import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
+import { DownloadItem } from "@/components/DownloadItem";
+import { OverviewText } from "@/components/OverviewText";
+import { ParallaxScrollView } from "@/components/ParallaxPage";
+import { PlayButton } from "@/components/PlayButton";
+import { PlayedStatus } from "@/components/PlayedStatus";
+import { SimilarItems } from "@/components/SimilarItems";
+import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
+import { ItemImage } from "@/components/common/ItemImage";
+import { CastAndCrew } from "@/components/series/CastAndCrew";
+import { CurrentSeries } from "@/components/series/CurrentSeries";
+import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+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 native from "@/utils/profiles/native";
+import old from "@/utils/profiles/old";
+import { 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 { useNavigation } from "expo-router";
+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 { Chromecast } from "./Chromecast";
+import { ItemHeader } from "./ItemHeader";
+import { MediaSourceSelector } from "./MediaSourceSelector";
+import Animated, {
+ useSharedValue,
+ useAnimatedStyle,
+ withTiming,
+ runOnJS,
+} from "react-native-reanimated";
+import { Loader } from "./Loader";
+import { set } from "lodash";
+import * as ScreenOrientation from "expo-screen-orientation";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
+ 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(0);
+ const [maxBitrate, setMaxBitrate] = useState({
+ key: "Max",
+ value: undefined,
+ });
+
+ const [loadingImage, setLoadingImage] = 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);
+ }
+ );
+
+ ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
+ setOrientation(initialOrientation);
+ });
+
+ 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,
+ });
+
+ return res;
+ },
+ enabled: !!id && !!api,
+ staleTime: 60 * 1000 * 5,
+ });
+
+ const [localItem, setLocalItem] = useState(item);
+
+ useEffect(() => {
+ if (item) {
+ if (localItem) {
+ // Fade out current item
+ fadeOut(() => {
+ // Update local item after fade out
+ setLocalItem(item);
+ // Then fade in
+ fadeIn();
+ });
+ } 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 && (
+
+
+
+
+
+ ),
+ });
+ }, [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]);
+
+ 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,
+ });
+
+ return playbackData.data;
+ },
+ enabled: !!item?.Id && !!api && !!user?.Id,
+ staleTime: 0,
+ });
+
+ 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 || loadingImage || (logoUrl && loadingLogo)
+ );
+ }, [isLoading, isFetching, loadingImage, loadingLogo, logoUrl]);
+
+ const insets = useSafeAreaInsets();
+
+ return (
+
+ {loading && (
+
+
+
+ )}
+
+
+ {localItem && (
+ setLoadingImage(false)}
+ onError={() => setLoadingImage(false)}
+ />
+ )}
+
+ >
+ }
+ logo={
+ <>
+ {logoUrl ? (
+ setLoadingLogo(false)}
+ onError={() => setLoadingLogo(false)}
+ />
+ ) : null}
+ >
+ }
+ >
+
+
+
+
+ {localItem ? (
+
+ setMaxBitrate(val)}
+ selected={maxBitrate}
+ />
+
+ {selectedMediaSource && (
+ <>
+
+
+ >
+ )}
+
+ ) : (
+
+
+
+
+ )}
+
+
+
+
+
+ {item?.Type === "Episode" && (
+
+ )}
+
+
+
+
+
+ {item?.Type === "Episode" && (
+
+ )}
+
+
+
+
+
+
+ );
+});
diff --git a/components/ItemHeader.tsx b/components/ItemHeader.tsx
new file mode 100644
index 00000000..dcffe023
--- /dev/null
+++ b/components/ItemHeader.tsx
@@ -0,0 +1,38 @@
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { View, ViewProps } from "react-native";
+import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
+import { Ratings } from "./Ratings";
+import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
+
+interface Props extends ViewProps {
+ item?: BaseItemDto | null;
+}
+
+export const ItemHeader: React.FC = ({ item, ...props }) => {
+ if (!item)
+ return (
+
+
+
+
+
+
+ );
+
+ return (
+
+
+ {item.Type === "Episode" && }
+ {item.Type === "Movie" && }
+
+ );
+};
diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx
new file mode 100644
index 00000000..1478837a
--- /dev/null
+++ b/components/MediaSourceSelector.tsx
@@ -0,0 +1,89 @@
+import { tc } from "@/utils/textTools";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { useEffect, useMemo } from "react";
+import { TouchableOpacity, View } from "react-native";
+import * as DropdownMenu from "zeego/dropdown-menu";
+import { Text } from "./common/Text";
+
+interface Props extends React.ComponentProps {
+ item: BaseItemDto;
+ onChange: (value: MediaSourceInfo) => void;
+ selected: MediaSourceInfo | null;
+}
+
+export const MediaSourceSelector: React.FC = ({
+ item,
+ onChange,
+ selected,
+ ...props
+}) => {
+ const mediaSources = useMemo(() => {
+ return item.MediaSources;
+ }, [item]);
+
+ const selectedMediaSource = useMemo(
+ () =>
+ mediaSources
+ ?.find((x) => x.Id === selected?.Id)
+ ?.MediaStreams?.find((x) => x.Type === "Video")?.DisplayTitle || "",
+ [mediaSources, selected]
+ );
+
+ useEffect(() => {
+ if (mediaSources?.length) onChange(mediaSources[0]);
+ }, [mediaSources]);
+
+ const name = (name?: string | null) => {
+ if (name && name.length > 40)
+ return (
+ name.substring(0, 20) + " [...] " + name.substring(name.length - 20)
+ );
+ return name;
+ };
+
+ return (
+
+
+
+
+ Video
+
+ {selectedMediaSource}
+
+
+
+
+ Media sources
+ {mediaSources?.map((source, idx: number) => (
+ {
+ onChange(source);
+ }}
+ >
+
+ {name(source.Name)}
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/components/OverviewText.tsx b/components/OverviewText.tsx
index 96075f08..1d448618 100644
--- a/components/OverviewText.tsx
+++ b/components/OverviewText.tsx
@@ -10,35 +10,32 @@ interface Props extends ViewProps {
export const OverviewText: React.FC = ({
text,
- characterLimit = 140,
+ characterLimit = 100,
...props
}) => {
const [limit, setLimit] = useState(characterLimit);
if (!text) return null;
- if (text.length > characterLimit)
- return (
+ return (
+
+ Overview
setLimit((prev) =>
prev === characterLimit ? text.length : characterLimit
)
}
- {...props}
>
-
+
{tc(text, limit)}
-
- {limit === characterLimit ? "Show more" : "Show less"}
-
+ {text.length > characterLimit && (
+
+ {limit === characterLimit ? "Show more" : "Show less"}
+
+ )}
- );
-
- return (
-
- {text}
);
};
diff --git a/components/ParallaxPage.tsx b/components/ParallaxPage.tsx
index 4f955c6b..daebca6b 100644
--- a/components/ParallaxPage.tsx
+++ b/components/ParallaxPage.tsx
@@ -1,27 +1,27 @@
-import { Ionicons } from "@expo/vector-icons";
-import { router } from "expo-router";
-import type { PropsWithChildren, ReactElement } from "react";
-import { TouchableOpacity, View } from "react-native";
+import { LinearGradient } from "expo-linear-gradient";
+import { type PropsWithChildren, type ReactElement } from "react";
+import { View, ViewProps } from "react-native";
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollViewOffset,
} from "react-native-reanimated";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { Chromecast } from "./Chromecast";
-const HEADER_HEIGHT = 400;
-
-type Props = PropsWithChildren<{
+interface Props extends ViewProps {
headerImage: ReactElement;
logo?: ReactElement;
-}>;
+ episodePoster?: ReactElement;
+ headerHeight?: number;
+}
-export const ParallaxScrollView: React.FC = ({
+export const ParallaxScrollView: React.FC> = ({
children,
headerImage,
+ episodePoster,
+ headerHeight = 400,
logo,
+ ...props
}: Props) => {
const scrollRef = useAnimatedRef();
const scrollOffset = useScrollViewOffset(scrollRef);
@@ -32,14 +32,14 @@ export const ParallaxScrollView: React.FC = ({
{
translateY: interpolate(
scrollOffset.value,
- [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
- [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
+ [-headerHeight, 0, headerHeight],
+ [-headerHeight / 2, 0, headerHeight * 0.75]
),
},
{
scale: interpolate(
scrollOffset.value,
- [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
+ [-headerHeight, 0, headerHeight],
[2, 1, 1]
),
},
@@ -47,10 +47,8 @@ export const ParallaxScrollView: React.FC = ({
};
});
- const inset = useSafeAreaInsets();
-
return (
-
+
= ({
scrollEventThrottle={16}
>
{logo && (
-
+
{logo}
)}
@@ -67,7 +71,7 @@ export const ParallaxScrollView: React.FC = ({
= ({
>
{headerImage}
-
+
+
+
+
{children}
diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx
index dc52976b..93211f07 100644
--- a/components/PlayButton.tsx
+++ b/components/PlayButton.tsx
@@ -1,14 +1,27 @@
import { usePlayback } from "@/providers/PlaybackProvider";
+import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { View } from "react-native";
+import { useAtom } from "jotai";
+import { useEffect, useMemo } from "react";
+import { TouchableOpacity, View } from "react-native";
import CastContext, {
PlayServicesState,
useRemoteMediaClient,
useMediaStatus,
} from "react-native-google-cast";
+import Animated, {
+ Easing,
+ interpolate,
+ interpolateColor,
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
import { Button } from "./Button";
import { isCancel } from "axios";
@@ -17,23 +30,36 @@ interface Props extends React.ComponentProps {
url?: string | null;
}
+const ANIMATION_DURATION = 500;
+const MIN_PLAYBACK_WIDTH = 15;
+
export const PlayButton: React.FC = ({ item, url, ...props }) => {
const { showActionSheetWithOptions } = useActionSheet();
- const client = useRemoteMediaClient();
const { setCurrentlyPlayingState } = usePlayback();
const mediaStatus = useMediaStatus()
+ const client = useRemoteMediaClient();
+
+ const [colorAtom] = useAtom(itemThemeColorAtom);
+
+ const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
+ const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
+
+ const startWidth = useSharedValue(0);
+ const targetWidth = useSharedValue(0);
+ const endColor = useSharedValue(memoizedColor);
+ const startColor = useSharedValue(memoizedColor);
+ const widthProgress = useSharedValue(0);
+ const colorChangeProgress = useSharedValue(0);
+
const onPress = async () => {
if (!url || !item) return;
-
if (!client) {
setCurrentlyPlayingState({ item, url });
return;
}
-
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
-
showActionSheetWithOptions(
{
options,
@@ -87,18 +113,141 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => {
);
};
+ const derivedTargetWidth = useDerivedValue(() => {
+ if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
+ const userData = memoizedItem.UserData;
+ if (userData && userData.PlaybackPositionTicks) {
+ return userData.PlaybackPositionTicks > 0
+ ? Math.max(
+ (userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
+ MIN_PLAYBACK_WIDTH
+ )
+ : 0;
+ }
+ return 0;
+ }, [memoizedItem]);
+
+ useAnimatedReaction(
+ () => derivedTargetWidth.value,
+ (newWidth) => {
+ targetWidth.value = newWidth;
+ widthProgress.value = 0;
+ widthProgress.value = withTiming(1, {
+ duration: ANIMATION_DURATION,
+ easing: Easing.bezier(0.7, 0, 0.3, 1.0),
+ });
+ },
+ [item]
+ );
+
+ useAnimatedReaction(
+ () => memoizedColor,
+ (newColor) => {
+ endColor.value = newColor;
+ colorChangeProgress.value = 0;
+ colorChangeProgress.value = withTiming(1, {
+ duration: ANIMATION_DURATION,
+ easing: Easing.bezier(0.9, 0, 0.31, 0.99),
+ });
+ },
+ [memoizedColor]
+ );
+
+ useEffect(() => {
+ const timeout_2 = setTimeout(() => {
+ startColor.value = memoizedColor;
+ startWidth.value = targetWidth.value;
+ }, ANIMATION_DURATION);
+
+ return () => {
+ clearTimeout(timeout_2);
+ };
+ }, [memoizedColor, memoizedItem]);
+
+ /**
+ * ANIMATED STYLES
+ */
+ const animatedAverageStyle = useAnimatedStyle(() => ({
+ backgroundColor: interpolateColor(
+ colorChangeProgress.value,
+ [0, 1],
+ [startColor.value.average, endColor.value.average]
+ ),
+ }));
+
+ const animatedPrimaryStyle = useAnimatedStyle(() => ({
+ backgroundColor: interpolateColor(
+ colorChangeProgress.value,
+ [0, 1],
+ [startColor.value.primary, endColor.value.primary]
+ ),
+ }));
+
+ const animatedWidthStyle = useAnimatedStyle(() => ({
+ width: `${interpolate(
+ widthProgress.value,
+ [0, 1],
+ [startWidth.value, targetWidth.value]
+ )}%`,
+ }));
+
+ const animatedTextStyle = useAnimatedStyle(() => ({
+ color: interpolateColor(
+ colorChangeProgress.value,
+ [0, 1],
+ [startColor.value.text, endColor.value.text]
+ ),
+ }));
+ /**
+ * *********************
+ */
+
return (
-
-
- {client && }
-
- }
+ className="relative"
{...props}
>
- {runtimeTicksToMinutes(item?.RunTimeTicks)}
-
+
+
+
+
+
+
+
+
+ {runtimeTicksToMinutes(item?.RunTimeTicks)}
+
+
+
+
+ {client && (
+
+
+
+ )}
+
+
+
);
};
diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx
index 1cb65034..b3b55ee9 100644
--- a/components/PlayedStatus.tsx
+++ b/components/PlayedStatus.tsx
@@ -7,9 +7,13 @@ import { useQueryClient } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import React from "react";
-import { TouchableOpacity, View } from "react-native";
+import { TouchableOpacity, View, ViewProps } from "react-native";
-export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
+interface Props extends ViewProps {
+ item: BaseItemDto;
+}
+
+export const PlayedStatus: React.FC = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -37,7 +41,10 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
};
return (
-
+
{item.UserData?.Played ? (
{
@@ -51,7 +58,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
}}
>
-
+
) : (
@@ -67,7 +74,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
}}
>
-
+
)}
diff --git a/components/Ratings.tsx b/components/Ratings.tsx
index f3d73168..51b6be3b 100644
--- a/components/Ratings.tsx
+++ b/components/Ratings.tsx
@@ -5,12 +5,13 @@ import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
interface Props extends ViewProps {
- item: BaseItemDto;
+ item?: BaseItemDto | null;
}
-export const Ratings: React.FC = ({ item }) => {
+export const Ratings: React.FC = ({ item, ...props }) => {
+ if (!item) return null;
return (
-
+
{item.OfficialRating && (
)}
diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx
index 45624a52..6777a8d9 100644
--- a/components/SimilarItems.tsx
+++ b/components/SimilarItems.tsx
@@ -6,23 +6,26 @@ import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
-import { ScrollView, TouchableOpacity, View } from "react-native";
+import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "./common/Text";
import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader";
-type SimilarItemsProps = {
- itemId: string;
-};
+interface SimilarItemsProps extends ViewProps {
+ itemId?: string | null;
+}
-export const SimilarItems: React.FC = ({ itemId }) => {
+export const SimilarItems: React.FC = ({
+ itemId,
+ ...props
+}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: similarItems, isLoading } = useQuery({
queryKey: ["similarItems", itemId],
queryFn: async () => {
- if (!api || !user?.Id) return [];
+ if (!api || !user?.Id || !itemId) return [];
const response = await getLibraryApi(api).getSimilarItems({
itemId,
userId: user.Id,
@@ -41,8 +44,8 @@ export const SimilarItems: React.FC = ({ itemId }) => {
);
return (
-
- Similar items
+
+ Similar items
{isLoading ? (
@@ -53,7 +56,7 @@ export const SimilarItems: React.FC = ({ itemId }) => {
{movies.map((item) => (
router.push(`/items/${item.Id}`)}
+ onPress={() => router.push(`/items/page?id=${item.Id}`)}
className="flex flex-col w-32"
>
@@ -63,7 +66,9 @@ export const SimilarItems: React.FC = ({ itemId }) => {
)}
- {movies.length === 0 && No similar items }
+ {movies.length === 0 && (
+ No similar items
+ )}
);
};
diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx
index 35918de1..3b53a7d9 100644
--- a/components/SubtitleTrackSelector.tsx
+++ b/components/SubtitleTrackSelector.tsx
@@ -2,29 +2,29 @@ import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
interface Props extends React.ComponentProps {
- item: BaseItemDto;
+ source: MediaSourceInfo;
onChange: (value: number) => void;
selected: number;
}
export const SubtitleTrackSelector: React.FC = ({
- item,
+ source,
onChange,
selected,
...props
}) => {
const subtitleStreams = useMemo(
- () =>
- item.MediaSources?.[0].MediaStreams?.filter(
- (x) => x.Type === "Subtitle"
- ) ?? [],
- [item]
+ () => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
+ [source]
);
const selectedSubtitleSteam = useMemo(
@@ -33,7 +33,7 @@ export const SubtitleTrackSelector: React.FC = ({
);
useEffect(() => {
- const index = item.MediaSources?.[0].DefaultSubtitleStreamIndex;
+ const index = source.DefaultSubtitleStreamIndex;
if (index !== undefined && index !== null) {
onChange(index);
} else {
@@ -44,20 +44,24 @@ export const SubtitleTrackSelector: React.FC = ({
if (subtitleStreams.length === 0) return null;
return (
-
+
-
- Subtitles
-
-
-
- {selectedSubtitleSteam
- ? tc(selectedSubtitleSteam?.DisplayTitle, 13)
- : "None"}
-
-
-
+
+ Subtitle
+
+
+ {selectedSubtitleSteam
+ ? tc(selectedSubtitleSteam?.DisplayTitle, 7)
+ : "None"}
+
+
= ({
collisionPadding={8}
sideOffset={8}
>
- Subtitles
+ Subtitle tracks
{
diff --git a/components/common/HeaderBackButton.tsx b/components/common/HeaderBackButton.tsx
index 4ff4c38d..023c1144 100644
--- a/components/common/HeaderBackButton.tsx
+++ b/components/common/HeaderBackButton.tsx
@@ -36,7 +36,7 @@ export const HeaderBackButton: React.FC = ({
className="drop-shadow-2xl"
name="arrow-back"
size={24}
- color="#077DF2"
+ color="white"
/>
@@ -45,7 +45,7 @@ export const HeaderBackButton: React.FC = ({
return (
router.back()}
- className=" bg-black rounded-full p-2 border border-neutral-900"
+ className=" bg-neutral-800/80 rounded-full p-2"
{...touchableOpacityProps}
>
= Partial & Pick;
+export interface HorizontalScrollRef {
+ scrollToIndex: (index: number, viewOffset: number) => void;
+}
+
interface HorizontalScrollProps
extends PartialExcept<
Omit, "renderItem">,
@@ -23,61 +21,69 @@ interface HorizontalScrollProps
loadingContainerStyle?: ViewStyle;
height?: number;
loading?: boolean;
+ extraData?: any;
}
-export function HorizontalScroll({
- data = [],
- renderItem,
- containerStyle,
- contentContainerStyle,
- loadingContainerStyle,
- loading = false,
- height = 164,
- ...props
-}: HorizontalScrollProps): React.ReactElement {
- const animatedOpacity = useSharedValue(0);
- const animatedStyle1 = useAnimatedStyle(() => {
- return {
- opacity: withTiming(animatedOpacity.value, { duration: 250 }),
- };
- });
+export const HorizontalScroll = forwardRef<
+ HorizontalScrollRef,
+ HorizontalScrollProps
+>(
+ (
+ {
+ data = [],
+ renderItem,
+ containerStyle,
+ contentContainerStyle,
+ loadingContainerStyle,
+ loading = false,
+ height = 164,
+ extraData,
+ ...props
+ }: HorizontalScrollProps,
+ ref: React.ForwardedRef
+ ) => {
+ const flashListRef = useRef>(null);
- useEffect(() => {
- if (data) {
- animatedOpacity.value = 1;
- }
- }, [data]);
+ useImperativeHandle(ref!, () => ({
+ scrollToIndex: (index: number, viewOffset: number) => {
+ flashListRef.current?.scrollToIndex({
+ index,
+ animated: true,
+ viewPosition: 0,
+ viewOffset,
+ });
+ },
+ }));
- if (data === undefined || data === null || loading) {
- return (
-
-
+ const renderFlashListItem = ({
+ item,
+ index,
+ }: {
+ item: T;
+ index: number;
+ }) => (
+
+ {renderItem(item, index)}
);
- }
- const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
-
- {renderItem(item, index)}
-
- );
+ if (!data || loading) {
+ return (
+
+
+
+
+ );
+ }
- return (
-
-
+ ref={flashListRef}
data={data}
+ extraData={extraData}
renderItem={renderFlashListItem}
horizontal
- estimatedItemSize={100}
+ estimatedItemSize={200}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 16,
@@ -90,6 +96,6 @@ export function HorizontalScroll({
)}
{...props}
/>
-
- );
-}
+ );
+ }
+);
diff --git a/components/common/ItemImage.tsx b/components/common/ItemImage.tsx
new file mode 100644
index 00000000..c3ad2ed6
--- /dev/null
+++ b/components/common/ItemImage.tsx
@@ -0,0 +1,97 @@
+import { useImageColors } from "@/hooks/useImageColors";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image, ImageProps, ImageSource } from "expo-image";
+import { useAtom } from "jotai";
+import { useMemo } from "react";
+
+interface Props extends ImageProps {
+ item: BaseItemDto;
+ variant?: "Backdrop" | "Primary" | "Thumb" | "Logo";
+ quality?: number;
+ width?: number;
+}
+
+export const ItemImage: React.FC = ({
+ item,
+ variant,
+ quality = 90,
+ width = 1000,
+ ...props
+}) => {
+ const [api] = useAtom(apiAtom);
+
+ const source = useMemo(() => {
+ if (!api) return null;
+
+ let tag: string | null | undefined;
+ let blurhash: string | null | undefined;
+ let src: ImageSource | null = null;
+
+ switch (variant) {
+ case "Backdrop":
+ if (item.Type === "Episode") {
+ tag = item.ParentBackdropImageTags?.[0];
+ if (!tag) break;
+ blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
+ src = {
+ uri: `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Backdrop/0?quality=${quality}&tag=${tag}`,
+ blurhash,
+ };
+ break;
+ }
+
+ tag = item.BackdropImageTags?.[0];
+ if (!tag) break;
+ blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
+ src = {
+ uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop/0?quality=${quality}&tag=${tag}`,
+ blurhash,
+ };
+ break;
+ case "Primary":
+ tag = item.ImageTags?.["Primary"];
+ if (!tag) break;
+ blurhash = item.ImageBlurHashes?.Primary?.[tag];
+
+ src = {
+ uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`,
+ blurhash,
+ };
+ break;
+ case "Thumb":
+ tag = item.ImageTags?.["Thumb"];
+ if (!tag) break;
+ blurhash = item.ImageBlurHashes?.Thumb?.[tag];
+
+ src = {
+ uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}`,
+ blurhash,
+ };
+ break;
+ default:
+ tag = item.ImageTags?.["Primary"];
+ src = {
+ uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`,
+ };
+ break;
+ }
+
+ return src;
+ }, [item.ImageTags]);
+
+ useImageColors(source?.uri);
+
+ return (
+
+ );
+};
diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx
index 8ca06867..6e9a8122 100644
--- a/components/common/TouchableItemRouter.tsx
+++ b/components/common/TouchableItemRouter.tsx
@@ -22,8 +22,6 @@ export const TouchableItemRouter: React.FC> = ({
return (
{
- console.log("[0]", item.Type);
-
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (item.Type === "Series") {
@@ -72,7 +70,7 @@ export const TouchableItemRouter: React.FC> = ({
// return;
// }
- router.push(`/(auth)/(tabs)/${from}/items/${item.Id}`);
+ router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`);
}}
{...props}
>
diff --git a/components/downloads/SeriesCard.tsx b/components/downloads/SeriesCard.tsx
index 96c63675..c010ca04 100644
--- a/components/downloads/SeriesCard.tsx
+++ b/components/downloads/SeriesCard.tsx
@@ -25,7 +25,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
return (
- {items[0].SeriesName}
+ {items[0].SeriesName}
{items.length}
diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx
index 63a64d9c..989a1ad8 100644
--- a/components/home/LargeMovieCarousel.tsx
+++ b/components/home/LargeMovieCarousel.tsx
@@ -47,7 +47,7 @@ export const LargeMovieCarousel: React.FC = ({ ...props }) => {
return response.data.Items?.[0].Id || null;
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
- staleTime: 0,
+ staleTime: 60 * 1000,
});
const onPressPagination = (index: number) => {
@@ -75,7 +75,7 @@ export const LargeMovieCarousel: React.FC = ({ ...props }) => {
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!sf_carousel,
- staleTime: 0,
+ staleTime: 60 * 1000,
});
const width = Dimensions.get("screen").width;
diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx
index 35b7dac1..ab4ef0ae 100644
--- a/components/home/ScrollingCollectionList.tsx
+++ b/components/home/ScrollingCollectionList.tsx
@@ -6,50 +6,72 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
+import {
+ type QueryKey,
+ useQuery,
+ type QueryFunction,
+} from "@tanstack/react-query";
+import SeriesPoster from "../posters/SeriesPoster";
+import { EpisodePoster } from "../posters/EpisodePoster";
interface Props extends ViewProps {
- title: string;
- loading?: boolean;
+ title?: string | null;
orientation?: "horizontal" | "vertical";
- data?: BaseItemDto[] | null;
height?: "small" | "large";
disabled?: boolean;
+ queryKey: QueryKey;
+ queryFn: QueryFunction;
}
export const ScrollingCollectionList: React.FC = ({
title,
- data,
orientation = "vertical",
height = "small",
- loading = false,
disabled = false,
+ queryFn,
+ queryKey,
...props
}) => {
- if (disabled) return null;
+ const { data, isLoading } = useQuery({
+ queryKey,
+ queryFn,
+ enabled: !disabled,
+ staleTime: 60 * 1000,
+ });
+
+ if (disabled || !title) return null;
return (
{title}
-
+ (
- {orientation === "vertical" ? (
-
- ) : (
+ {item.Type === "Episode" && orientation === "horizontal" && (
)}
+ {item.Type === "Episode" && orientation === "vertical" && (
+
+ )}
+ {item.Type === "Movie" && orientation === "horizontal" && (
+
+ )}
+ {item.Type === "Movie" && orientation === "vertical" && (
+
+ )}
+ {item.Type === "Series" && }
diff --git a/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx
index 87de1870..a5d21e11 100644
--- a/components/library/LibraryItemCard.tsx
+++ b/components/library/LibraryItemCard.tsx
@@ -107,7 +107,7 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => {
});
})
.catch((error) => {
- console.log("Error getting colors", error);
+ console.error("Error getting colors", error);
});
}
}, [url]);
diff --git a/components/medialists/MediaListSection.tsx b/components/medialists/MediaListSection.tsx
index 9b013e16..93b7a5c0 100644
--- a/components/medialists/MediaListSection.tsx
+++ b/components/medialists/MediaListSection.tsx
@@ -12,22 +12,38 @@ import { Text } from "../common/Text";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText";
import MoviePoster from "../posters/MoviePoster";
+import {
+ type QueryKey,
+ type QueryFunction,
+ useQuery,
+} from "@tanstack/react-query";
interface Props extends ViewProps {
- collection: BaseItemDto;
+ queryKey: QueryKey;
+ queryFn: QueryFunction;
}
-export const MediaListSection: React.FC = ({ collection, ...props }) => {
+export const MediaListSection: React.FC = ({
+ queryFn,
+ queryKey,
+ ...props
+}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
+ const { data: collection, isLoading } = useQuery({
+ queryKey,
+ queryFn,
+ staleTime: 60 * 1000,
+ });
+
const fetchItems = useCallback(
async ({
pageParam,
}: {
pageParam: number;
}): Promise => {
- if (!api || !user?.Id) return null;
+ if (!api || !user?.Id || !collection) return null;
const response = await getItemsApi(api).getItems({
userId: user.Id,
@@ -38,7 +54,7 @@ export const MediaListSection: React.FC = ({ collection, ...props }) => {
return response.data;
},
- [api, user?.Id, collection.Id]
+ [api, user?.Id, collection?.Id]
);
if (!collection) return null;
diff --git a/components/movies/MoviesTitleHeader.tsx b/components/movies/MoviesTitleHeader.tsx
index 771cdb01..0877cf10 100644
--- a/components/movies/MoviesTitleHeader.tsx
+++ b/components/movies/MoviesTitleHeader.tsx
@@ -1,17 +1,16 @@
-import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
-import { useRouter } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { View, ViewProps } from "react-native";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const MoviesTitleHeader: React.FC = ({ item, ...props }) => {
- const router = useRouter();
return (
-
- {item?.Name}
+
+ {item?.Name}
+ {item?.ProductionYear}
);
};
diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx
index d2688b5d..76ed9f73 100644
--- a/components/music/SongsListItem.tsx
+++ b/components/music/SongsListItem.tsx
@@ -71,7 +71,10 @@ export const SongsListItem: React.FC = ({
};
const play = async (type: "device" | "cast") => {
- if (!user?.Id || !api || !item.Id) return;
+ if (!user?.Id || !api || !item.Id) {
+ console.warn("No user, api or item", user, api, item.Id);
+ return;
+ }
const response = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: item?.Id,
@@ -87,9 +90,13 @@ export const SongsListItem: React.FC = ({
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
+ mediaSourceId: item.Id,
});
- if (!url || !item) return;
+ if (!url || !item) {
+ console.warn("No url or item", url, item.Id);
+ return;
+ }
if (type === "cast" && client) {
await CastContext.getPlayServicesState().then((state) => {
@@ -111,6 +118,7 @@ export const SongsListItem: React.FC = ({
}
});
} else {
+ console.log("Playing on device", url, item.Id);
setCurrentlyPlayingState({
item,
url,
diff --git a/components/posters/EpisodePoster.tsx b/components/posters/EpisodePoster.tsx
new file mode 100644
index 00000000..c82464d5
--- /dev/null
+++ b/components/posters/EpisodePoster.tsx
@@ -0,0 +1,64 @@
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { useAtom } from "jotai";
+import { useMemo, useState } from "react";
+import { View } from "react-native";
+import { WatchedIndicator } from "@/components/WatchedIndicator";
+
+type MoviePosterProps = {
+ item: BaseItemDto;
+ showProgress?: boolean;
+};
+
+export const EpisodePoster: React.FC = ({
+ item,
+ showProgress = false,
+}) => {
+ const [api] = useAtom(apiAtom);
+
+ const url = useMemo(() => {
+ if (item.Type === "Episode") {
+ return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
+ }
+ }, [item]);
+
+ const [progress, setProgress] = useState(
+ item.UserData?.PlayedPercentage || 0
+ );
+
+ const blurhash = useMemo(() => {
+ const key = item.ImageTags?.["Primary"] as string;
+ return item.ImageBlurHashes?.["Primary"]?.[key];
+ }, [item]);
+
+ return (
+
+
+
+ {showProgress && progress > 0 && (
+
+ )}
+
+ );
+};
diff --git a/components/posters/MoviePoster.tsx b/components/posters/MoviePoster.tsx
index 972392b6..056e2c30 100644
--- a/components/posters/MoviePoster.tsx
+++ b/components/posters/MoviePoster.tsx
@@ -1,3 +1,4 @@
+import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
@@ -5,7 +6,6 @@ import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
-import { WatchedIndicator } from "@/components/WatchedIndicator";
type MoviePosterProps = {
item: BaseItemDto;
@@ -18,15 +18,13 @@ const MoviePoster: React.FC = ({
}) => {
const [api] = useAtom(apiAtom);
- const url = useMemo(
- () =>
- getPrimaryImageUrl({
- api,
- item,
- width: 300,
- }),
- [item]
- );
+ const url = useMemo(() => {
+ return getPrimaryImageUrl({
+ api,
+ item,
+ width: 300,
+ });
+ }, [item]);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
@@ -59,6 +57,7 @@ const MoviePoster: React.FC = ({
width: "100%",
}}
/>
+
{showProgress && progress > 0 && (
diff --git a/components/posters/SeriesPoster.tsx b/components/posters/SeriesPoster.tsx
index d235639f..dbadcdce 100644
--- a/components/posters/SeriesPoster.tsx
+++ b/components/posters/SeriesPoster.tsx
@@ -15,14 +15,16 @@ type MoviePosterProps = {
const SeriesPoster: React.FC = ({ item }) => {
const [api] = useAtom(apiAtom);
- const url = useMemo(
- () =>
- getPrimaryImageUrl({
- api,
- item,
- }),
- [item]
- );
+ const url = useMemo(() => {
+ if (item.Type === "Episode") {
+ return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=389&quality=80&tag=${item.SeriesPrimaryImageTag}`;
+ }
+ return getPrimaryImageUrl({
+ api,
+ item,
+ width: 300,
+ });
+ }, [item]);
const blurhash = useMemo(() => {
const key = item.ImageTags?.["Primary"] as string;
diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx
index ab041bd7..16f5e0d7 100644
--- a/components/series/CastAndCrew.tsx
+++ b/components/series/CastAndCrew.tsx
@@ -1,30 +1,31 @@
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
+import { router } from "expo-router";
+import { useAtom } from "jotai";
import React from "react";
-import { Linking, TouchableOpacity, View } from "react-native";
+import { TouchableOpacity, View, ViewProps } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import Poster from "../posters/Poster";
-import { useAtom } from "jotai";
-import { apiAtom } from "@/providers/JellyfinProvider";
-import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
-import { router, usePathname } from "expo-router";
-import { useSettings } from "@/utils/atoms/settings";
-export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
+interface Props extends ViewProps {
+ item?: BaseItemDto | null;
+ loading?: boolean;
+}
+
+export const CastAndCrew: React.FC = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
- const [settings] = useSettings();
-
- const pathname = usePathname();
-
return (
-
+
Cast & Crew
- >
- data={item.People}
+ (
{
diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx
index a71dea8d..f45792d0 100644
--- a/components/series/CurrentSeries.tsx
+++ b/components/series/CurrentSeries.tsx
@@ -3,19 +3,23 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { useAtom } from "jotai";
import React from "react";
-import { TouchableOpacity, View } from "react-native";
+import { TouchableOpacity, View, ViewProps } from "react-native";
import Poster from "../posters/Poster";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
-export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
+interface Props extends ViewProps {
+ item?: BaseItemDto | null;
+}
+
+export const CurrentSeries: React.FC = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
return (
-
+
Series
-
+ (
= ({ item, ...props }) => {
+ const router = useRouter();
+
+ return (
+
+ {item?.Name}
+
+ {
+ router.push(
+ // @ts-ignore
+ `/(auth)/series/${item.SeriesId}?seasonIndex=${item?.ParentIndexNumber}`
+ );
+ }}
+ >
+ {item?.SeasonName}
+
+
+ {"—"}
+ {`Episode ${item.IndexNumber}`}
+
+ {item?.ProductionYear}
+
+ );
+};
diff --git a/components/series/NextEpisodeButton.tsx b/components/series/NextEpisodeButton.tsx
index 709c9bfd..11140fdd 100644
--- a/components/series/NextEpisodeButton.tsx
+++ b/components/series/NextEpisodeButton.tsx
@@ -23,40 +23,6 @@ export const NextEpisodeButton: React.FC = ({
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
- // const { data: seasons } = useQuery({
- // queryKey: ["seasons", item.SeriesId],
- // queryFn: async () => {
- // if (
- // !api ||
- // !user?.Id ||
- // !item?.Id ||
- // !item?.SeriesId ||
- // !item?.IndexNumber
- // )
- // return [];
-
- // const response = await getItemsApi(api).getItems({
- // parentId: item?.SeriesId,
- // });
-
- // console.log("seasons ~", type, response.data);
-
- // return (response.data.Items as BaseItemDto[]) ?? [];
- // },
- // enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
- // });
-
- // const nextSeason = useMemo(() => {
- // if (!seasons) return null;
- // const currentSeasonIndex = seasons.findIndex(
- // (season) => season.Id === item.SeasonId,
- // );
-
- // if (currentSeasonIndex === seasons.length - 1) return null;
-
- // return seasons[currentSeasonIndex + 1];
- // }, [seasons]);
-
const { data: nextEpisode } = useQuery({
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
queryFn: async () => {
@@ -90,7 +56,7 @@ export const NextEpisodeButton: React.FC = ({
return (
router.push(`/items/${nextEpisode?.Id}`)}
+ onPress={() => router.setParams({ id: nextEpisode?.Id })}
className={`h-12 aspect-square`}
disabled={disabled}
{...props}
diff --git a/components/series/NextUp.tsx b/components/series/NextUp.tsx
index e4ac462d..401bcdd8 100644
--- a/components/series/NextUp.tsx
+++ b/components/series/NextUp.tsx
@@ -34,7 +34,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
if (!items?.length)
return (
-
+
Next up
No items to display
@@ -43,17 +43,17 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
return (
Next up
-
+ (
{
- router.push(`/(auth)/items/${item.Id}`);
+ router.push(`/(auth)/items/page?id=${item.Id}`);
}}
key={item.Id}
className="flex flex-col w-44"
>
-
+
)}
diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx
new file mode 100644
index 00000000..43bd6780
--- /dev/null
+++ b/components/series/SeasonEpisodesCarousel.tsx
@@ -0,0 +1,143 @@
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { router } from "expo-router";
+import { useAtom } from "jotai";
+import { useEffect, useMemo, useRef } from "react";
+import { TouchableOpacity, View, ViewProps } from "react-native";
+import {
+ HorizontalScroll,
+ HorizontalScrollRef,
+} from "../common/HorrizontalScroll";
+import ContinueWatchingPoster from "../ContinueWatchingPoster";
+import { ItemCardText } from "../ItemCardText";
+
+interface Props extends ViewProps {
+ item?: BaseItemDto | null;
+ loading?: boolean;
+}
+
+export const SeasonEpisodesCarousel: React.FC = ({
+ item,
+ loading,
+ ...props
+}) => {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+
+ const scrollRef = useRef(null);
+
+ const scrollToIndex = (index: number) => {
+ scrollRef.current?.scrollToIndex(index, 16);
+ };
+
+ const seasonId = useMemo(() => {
+ return item?.SeasonId;
+ }, [item]);
+
+ const {
+ data: episodes,
+ isLoading,
+ isFetching,
+ } = useQuery({
+ queryKey: ["episodes", seasonId],
+ queryFn: async () => {
+ if (!api || !user?.Id) return [];
+ const response = await api.axiosInstance.get(
+ `${api.basePath}/Shows/${item?.Id}/Episodes`,
+ {
+ params: {
+ userId: user?.Id,
+ seasonId,
+ Fields:
+ "ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
+ },
+ headers: {
+ Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
+ },
+ }
+ );
+
+ return response.data.Items as BaseItemDto[];
+ },
+ enabled: !!api && !!user?.Id && !!seasonId,
+ });
+
+ /**
+ * Prefetch previous and next episode
+ */
+ const queryClient = useQueryClient();
+ useEffect(() => {
+ if (!item?.Id || !item.IndexNumber || !episodes || episodes.length === 0) {
+ return;
+ }
+
+ const previousId = episodes?.find(
+ (ep) => ep.IndexNumber === item.IndexNumber! - 1
+ )?.Id;
+ if (previousId) {
+ queryClient.prefetchQuery({
+ queryKey: ["item", previousId],
+ queryFn: async () =>
+ await getUserItemData({
+ api,
+ userId: user?.Id,
+ itemId: previousId,
+ }),
+ staleTime: 60 * 1000,
+ });
+ }
+
+ const nextId = episodes?.find(
+ (ep) => ep.IndexNumber === item.IndexNumber! + 1
+ )?.Id;
+ if (nextId) {
+ queryClient.prefetchQuery({
+ queryKey: ["item", nextId],
+ queryFn: async () =>
+ await getUserItemData({
+ api,
+ userId: user?.Id,
+ itemId: nextId,
+ }),
+ staleTime: 60 * 1000,
+ });
+ }
+ }, [episodes, api, user?.Id, item]);
+
+ useEffect(() => {
+ if (item?.Type === "Episode" && item.Id) {
+ const index = episodes?.findIndex((ep) => ep.Id === item.Id);
+ if (index !== undefined && index !== -1) {
+ setTimeout(() => {
+ scrollToIndex(index);
+ }, 400);
+ }
+ }
+ }, [episodes, item]);
+
+ return (
+ (
+ {
+ router.setParams({ id: _item.Id });
+ }}
+ className={`flex flex-col w-44
+ ${item?.Id === _item.Id ? "" : "opacity-50"}
+ `}
+ >
+
+
+
+ )}
+ {...props}
+ />
+ );
+};
diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx
index 9cef17a3..1785e233 100644
--- a/components/series/SeasonPicker.tsx
+++ b/components/series/SeasonPicker.tsx
@@ -1,7 +1,7 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToSeconds } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useQuery } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
@@ -11,9 +11,14 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { DownloadItem } from "../DownloadItem";
import { Loader } from "../Loader";
import { Text } from "../common/Text";
+import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
+import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
+import { Image } from "expo-image";
+import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
type Props = {
item: BaseItemDto;
+ initialSeasonIndex?: number;
};
type SeasonIndexState = {
@@ -22,7 +27,7 @@ type SeasonIndexState = {
export const seasonIndexAtom = atom({});
-export const SeasonPicker: React.FC = ({ item }) => {
+export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
@@ -57,15 +62,35 @@ export const SeasonPicker: React.FC = ({ item }) => {
useEffect(() => {
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
- const firstSeason = seasons[0];
- if (firstSeason.IndexNumber !== undefined) {
+ let initialIndex: number | undefined;
+
+ if (initialSeasonIndex !== undefined) {
+ // Use the provided initialSeasonIndex if it exists in the seasons
+ const seasonExists = seasons.some(
+ (season: any) => season.IndexNumber === initialSeasonIndex
+ );
+ if (seasonExists) {
+ initialIndex = initialSeasonIndex;
+ }
+ }
+
+ if (initialIndex === undefined) {
+ // Fall back to the previous logic if initialIndex is not set
+ const season1 = seasons.find((season: any) => season.IndexNumber === 1);
+ const season0 = seasons.find((season: any) => season.IndexNumber === 0);
+ const firstSeason = season1 || season0 || seasons[0];
+ initialIndex = firstSeason.IndexNumber;
+ }
+
+ if (initialIndex !== undefined) {
setSeasonIndexState((prev) => ({
...prev,
- [item.Id ?? ""]: firstSeason.IndexNumber,
+ [item.Id ?? ""]: initialIndex,
}));
}
}
- }, [seasons, seasonIndex, setSeasonIndexState, item.Id]);
+ }, [seasons, seasonIndex, setSeasonIndexState, item.Id, initialSeasonIndex]);
+
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
@@ -75,27 +100,39 @@ export const SeasonPicker: React.FC = ({ item }) => {
const { data: episodes, isFetching } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
- if (!api || !user?.Id || !item.Id) return [];
- const response = await api.axiosInstance.get(
- `${api.basePath}/Shows/${item.Id}/Episodes`,
- {
- params: {
- userId: user?.Id,
- seasonId: selectedSeasonId,
- Fields:
- "ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
- },
- headers: {
- Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
- },
- }
- );
+ if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
+ const res = await getTvShowsApi(api).getEpisodes({
+ seriesId: item.Id,
+ userId: user.Id,
+ seasonId: selectedSeasonId,
+ enableUserData: true,
+ fields: ["MediaSources", "MediaStreams", "Overview"],
+ });
- return response.data.Items as BaseItemDto[];
+ return res.data.Items;
},
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
+ const queryClient = useQueryClient();
+ useEffect(() => {
+ for (let e of episodes || []) {
+ queryClient.prefetchQuery({
+ queryKey: ["item", e.Id],
+ queryFn: async () => {
+ if (!e.Id) return;
+ const res = await getUserItemData({
+ api,
+ userId: user?.Id,
+ itemId: e.Id,
+ });
+ return res;
+ },
+ staleTime: 60 * 5 * 1000,
+ });
+ }
+ }, [episodes]);
+
// Used for height calculation
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
useEffect(() => {
@@ -143,26 +180,6 @@ export const SeasonPicker: React.FC = ({ item }) => {
))}
- {/* Old View. Might have a setting later to manually select view. */}
- {/* {episodes && (
-
-
- data={episodes}
- renderItem={(item, index) => (
- {
- router.push(`/(auth)/items/${item.Id}`);
- }}
- className="flex flex-col w-48"
- >
-
-
-
- )}
- />
-
- )} */}
{isFetching ? (
= ({ item }) => {
{
- router.push(`/(auth)/items/${e.Id}`);
+ router.push(`/(auth)/items/page?id=${e.Id}`);
}}
className="flex flex-col mb-4"
>
-
+
diff --git a/components/series/SeriesTitleHeader.tsx b/components/series/SeriesTitleHeader.tsx
deleted file mode 100644
index 16e05ae2..00000000
--- a/components/series/SeriesTitleHeader.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { TouchableOpacity, View, ViewProps } from "react-native";
-import { Text } from "@/components/common/Text";
-import { useRouter } from "expo-router";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-
-interface Props extends ViewProps {
- item: BaseItemDto;
-}
-
-export const SeriesTitleHeader: React.FC = ({ item, ...props }) => {
- const router = useRouter();
- return (
- <>
- router.push(`/(auth)/series/${item.SeriesId}`)}
- >
- {item?.SeriesName}
-
-
-
- {item?.Name}
-
-
-
-
- {}}>
- {item?.SeasonName}
-
- {"—"}
-
- {`Episode ${item.IndexNumber}`}
-
-
-
- >
- );
-};
diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx
index 43859c38..c10f84f9 100644
--- a/components/settings/SettingToggles.tsx
+++ b/components/settings/SettingToggles.tsx
@@ -58,7 +58,7 @@ export const SettingToggles: React.FC = () => {
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
- {
Download quality
- Choose the search engine you want to use.
+ Choose the download quality.
@@ -97,7 +97,7 @@ export const SettingToggles: React.FC = () => {
))}
-
+ */}
Start videos in fullscreen
diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx
index 85dd4c04..32caef76 100644
--- a/components/stacks/NestedTabPageStack.tsx
+++ b/components/stacks/NestedTabPageStack.tsx
@@ -8,7 +8,6 @@ const commonScreenOptions = {
headerTransparent: true,
headerShadowVisible: false,
headerLeft: () => ,
- headerRight: () => ,
};
const routes = [
@@ -17,8 +16,7 @@ const routes = [
"artists/index",
"artists/[artistId]",
"collections/[collectionId]",
- "items/[id]",
- "songs/[songId]",
+ "items/page",
"series/[id]",
];
diff --git a/eas.json b/eas.json
index db05e9f1..102494e1 100644
--- a/eas.json
+++ b/eas.json
@@ -21,13 +21,13 @@
}
},
"production": {
- "channel": "0.8.2",
+ "channel": "0.10.3",
"android": {
"image": "latest"
}
},
"production-apk": {
- "channel": "0.8.2",
+ "channel": "0.10.3",
"android": {
"buildType": "apk",
"image": "latest"
diff --git a/hooks/useImageColors.ts b/hooks/useImageColors.ts
new file mode 100644
index 00000000..7d93e8ce
--- /dev/null
+++ b/hooks/useImageColors.ts
@@ -0,0 +1,42 @@
+import { useState, useEffect } from "react";
+import { getColors } from "react-native-image-colors";
+import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
+import { useAtom } from "jotai";
+
+export const useImageColors = (uri: string | undefined | null) => {
+ const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
+
+ useEffect(() => {
+ if (uri) {
+ getColors(uri, {
+ fallback: "#fff",
+ cache: true,
+ key: uri,
+ })
+ .then((colors) => {
+ let primary: string = "#fff";
+ let average: string = "#fff";
+ let secondary: string = "#fff";
+
+ if (colors.platform === "android") {
+ primary = colors.dominant;
+ average = colors.average;
+ secondary = colors.muted;
+ } else if (colors.platform === "ios") {
+ primary = colors.primary;
+ secondary = colors.detail;
+ average = colors.background;
+ }
+
+ setPrimaryColor({
+ primary,
+ secondary,
+ average,
+ });
+ })
+ .catch((error) => {
+ console.error("Error getting colors", error);
+ });
+ }
+ }, [uri, setPrimaryColor]);
+};
diff --git a/hooks/useInterval.ts b/hooks/useInterval.ts
new file mode 100644
index 00000000..850f2f48
--- /dev/null
+++ b/hooks/useInterval.ts
@@ -0,0 +1,19 @@
+import { useEffect, useRef } from "react";
+
+export function useInterval(callback: () => void, delay: number | null) {
+ const savedCallback = useRef<() => void>();
+
+ useEffect(() => {
+ savedCallback.current = callback;
+ }, [callback]);
+
+ useEffect(() => {
+ function tick() {
+ savedCallback.current?.();
+ }
+ if (delay !== null) {
+ const id = setInterval(tick, delay);
+ return () => clearInterval(id);
+ }
+ }, [delay]);
+}
diff --git a/package.json b/package.json
index eb62667e..d354102c 100644
--- a/package.json
+++ b/package.json
@@ -30,16 +30,17 @@
"@types/lodash": "^4.17.7",
"@types/uuid": "^10.0.0",
"axios": "^1.7.3",
- "expo": "~51.0.28",
+ "expo": "~51.0.31",
"expo-blur": "~13.0.2",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
- "expo-dev-client": "~4.0.23",
+ "expo-dev-client": "~4.0.25",
"expo-device": "~6.0.2",
"expo-font": "~12.0.9",
"expo-haptics": "~13.0.1",
- "expo-image": "~1.12.14",
+ "expo-image": "~1.12.15",
"expo-keep-awake": "~13.0.2",
+ "expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-navigation-bar": "~3.0.7",
"expo-router": "~3.5.23",
@@ -48,7 +49,7 @@
"expo-splash-screen": "~0.27.5",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
- "expo-updates": "~0.25.22",
+ "expo-updates": "~0.25.24",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"jotai": "^2.9.1",
@@ -72,7 +73,7 @@
"react-native-svg": "15.2.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2",
- "react-native-video": "^6.4.3",
+ "react-native-video": "^6.4.5",
"react-native-web": "~0.19.10",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.3",
diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx
index ec6a7e77..f083f15d 100644
--- a/providers/JellyfinProvider.tsx
+++ b/providers/JellyfinProvider.tsx
@@ -1,15 +1,19 @@
+import { useInterval } from "@/hooks/useInterval";
import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQuery } from "@tanstack/react-query";
-import axios from "axios";
+import axios, { AxiosError } from "axios";
import { router, useSegments } from "expo-router";
import { atom, useAtom } from "jotai";
import React, {
createContext,
ReactNode,
+ useCallback,
useContext,
useEffect,
+ useMemo,
useState,
} from "react";
import { Platform } from "react-native";
@@ -29,6 +33,7 @@ interface JellyfinContextValue {
removeServer: () => void;
login: (username: string, password: string) => Promise;
logout: () => Promise;
+ initiateQuickConnect: () => Promise;
}
const JellyfinContext = createContext(
@@ -51,7 +56,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}) => {
const [jellyfin, setJellyfin] = useState(undefined);
const [deviceId, setDeviceId] = useState(undefined);
- const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
(async () => {
@@ -59,7 +63,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
- clientInfo: { name: "Streamyfin", version: "0.8.2" },
+ clientInfo: { name: "Streamyfin", version: "0.10.3" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
})
);
@@ -69,6 +73,101 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [api, setApi] = useAtom(apiAtom);
const [user, setUser] = useAtom(userAtom);
+ const [isPolling, setIsPolling] = useState(false);
+ const [secret, setSecret] = useState(null);
+
+ useQuery({
+ queryKey: ["user", api],
+ queryFn: async () => {
+ if (!api) return null;
+ const response = await getUserApi(api).getCurrentUser();
+ if (response.data) setUser(response.data);
+ return user;
+ },
+ enabled: !!api,
+ refetchOnWindowFocus: true,
+ refetchInterval: 1000 * 60,
+ refetchIntervalInBackground: true,
+ refetchOnMount: true,
+ refetchOnReconnect: true,
+ });
+
+ const headers = useMemo(() => {
+ if (!deviceId) return {};
+ return {
+ authorization: `MediaBrowser Client="Streamyfin", Device=${
+ Platform.OS === "android" ? "Android" : "iOS"
+ }, DeviceId="${deviceId}", Version="0.10.3"`,
+ };
+ }, [deviceId]);
+
+ const initiateQuickConnect = useCallback(async () => {
+ if (!api || !deviceId) return;
+ try {
+ const response = await api.axiosInstance.post(
+ api.basePath + "/QuickConnect/Initiate",
+ null,
+ {
+ headers,
+ }
+ );
+ if (response?.status === 200) {
+ setSecret(response?.data?.Secret);
+ setIsPolling(true);
+ return response.data?.Code;
+ } else {
+ throw new Error("Failed to initiate quick connect");
+ }
+ } catch (error) {
+ console.error(error);
+ throw error;
+ }
+ }, [api, deviceId, headers]);
+
+ const pollQuickConnect = useCallback(async () => {
+ if (!api || !secret) return;
+
+ try {
+ const response = await api.axiosInstance.get(
+ `${api.basePath}/QuickConnect/Connect?Secret=${secret}`
+ );
+
+ if (response.status === 200) {
+ if (response.data.Authenticated) {
+ setIsPolling(false);
+
+ const authResponse = await api.axiosInstance.post(
+ api.basePath + "/Users/AuthenticateWithQuickConnect",
+ {
+ secret,
+ },
+ {
+ headers,
+ }
+ );
+
+ const { AccessToken, User } = authResponse.data;
+ api.accessToken = AccessToken;
+ setUser(User);
+ await AsyncStorage.setItem("token", AccessToken);
+ await AsyncStorage.setItem("user", JSON.stringify(User));
+ return true;
+ }
+ }
+ return false;
+ } catch (error) {
+ if (error instanceof AxiosError && error.response?.status === 400) {
+ setIsPolling(false);
+ setSecret(null);
+ throw new Error("The code has expired. Please try again.");
+ } else {
+ console.error("Error polling Quick Connect:", error);
+ throw error;
+ }
+ }
+ }, [api, secret, headers]);
+
+ useInterval(pollQuickConnect, isPolling ? 1000 : null);
const discoverServers = async (url: string): Promise => {
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
@@ -122,7 +221,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
} catch (error) {
if (axios.isAxiosError(error)) {
- console.log("Axios error", error.response?.status);
switch (error.response?.status) {
case 401:
throw new Error("Invalid username or password");
@@ -199,6 +297,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
login: (username, password) =>
loginMutation.mutateAsync({ username, password }),
logout: () => logoutMutation.mutateAsync(),
+ initiateQuickConnect,
};
useProtectedRoute(user, isLoading || isFetching);
diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx
index 5031c8ac..14fcf123 100644
--- a/providers/PlaybackProvider.tsx
+++ b/providers/PlaybackProvider.tsx
@@ -10,19 +10,21 @@ import React, {
} from "react";
import { useSettings } from "@/utils/atoms/settings";
+import { getDeviceId } from "@/utils/device";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
+import { postCapabilities } from "@/utils/jellyfin/session/capabilities";
import {
BaseItemDto,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
+import * as Linking from "expo-linking";
import { useAtom } from "jotai";
+import { debounce } from "lodash";
+import { Alert, Platform } from "react-native";
import { OnProgressData, type VideoRef } from "react-native-video";
import { apiAtom, userAtom } from "./JellyfinProvider";
-import { getDeviceId } from "@/utils/device";
-import * as Linking from "expo-linking";
-import { Platform } from "react-native";
type CurrentlyPlayingState = {
url: string;
@@ -36,14 +38,15 @@ interface PlaybackContextType {
isPlaying: boolean;
isFullscreen: boolean;
progressTicks: number | null;
- playVideo: () => void;
- pauseVideo: () => void;
+ playVideo: (triggerRef?: boolean) => void;
+ pauseVideo: (triggerRef?: boolean) => void;
stopPlayback: () => void;
presentFullscreenPlayer: () => void;
dismissFullscreenPlayer: () => void;
setIsFullscreen: (isFullscreen: boolean) => void;
setIsPlaying: (isPlaying: boolean) => void;
onProgress: (data: OnProgressData) => void;
+ setVolume: (volume: number) => void;
setCurrentlyPlayingState: (
currentlyPlaying: CurrentlyPlayingState | null
) => void;
@@ -61,9 +64,13 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
const [settings] = useSettings();
- const [isPlaying, setIsPlaying] = useState(false);
+ const previousVolume = useRef(null);
+
+ const [isPlaying, _setIsPlaying] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [progressTicks, setProgressTicks] = useState(0);
+ const [volume, _setVolume] = useState(null);
+ const [session, setSession] = useState(null);
const [currentlyPlaying, setCurrentlyPlaying] =
useState(null);
@@ -71,18 +78,14 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
const [ws, setWs] = useState(null);
const [isConnected, setIsConnected] = useState(false);
- const { data: sessionData } = useQuery({
- queryKey: ["sessionData", currentlyPlaying?.item.Id, user?.Id, api],
- queryFn: async () => {
- if (!currentlyPlaying?.item.Id) return null;
- const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
- itemId: currentlyPlaying?.item.Id,
- userId: user?.Id,
- });
- return playbackData.data;
+ const setVolume = useCallback(
+ (newVolume: number) => {
+ previousVolume.current = volume;
+ _setVolume(newVolume);
+ videoRef.current?.setVolume(newVolume);
},
- enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
- });
+ [_setVolume]
+ );
const { data: deviceId } = useQuery({
queryKey: ["deviceId", api],
@@ -90,15 +93,28 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
});
const setCurrentlyPlayingState = useCallback(
- (state: CurrentlyPlayingState | null) => {
- const vlcLink = "vlc://" + state?.url;
- console.log(vlcLink, settings?.openInVLC, Platform.OS === "ios");
- if (vlcLink && settings?.openInVLC) {
- Linking.openURL("vlc://" + state?.url || "");
- return;
- }
+ async (state: CurrentlyPlayingState | null) => {
+ if (!api) return;
- if (state) {
+ if (state && state.item.Id && user?.Id) {
+ const vlcLink = "vlc://" + state?.url;
+ if (vlcLink && settings?.openInVLC) {
+ Linking.openURL("vlc://" + state?.url || "");
+ return;
+ }
+
+ const res = await getMediaInfoApi(api).getPlaybackInfo({
+ itemId: state.item.Id,
+ userId: user.Id,
+ });
+
+ await postCapabilities({
+ api,
+ itemId: state.item.Id,
+ sessionId: res.data.PlaySessionId,
+ });
+
+ setSession(res.data);
setCurrentlyPlaying(state);
setIsPlaying(true);
@@ -113,62 +129,86 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
setIsPlaying(false);
}
},
- [settings]
+ [settings, user, api]
);
- // Define control methods
- const playVideo = useCallback(() => {
- videoRef.current?.resume();
- setIsPlaying(true);
- reportPlaybackProgress({
- api,
- itemId: currentlyPlaying?.item.Id,
- positionTicks: progressTicks ? progressTicks : 0,
- sessionId: sessionData?.PlaySessionId,
- IsPaused: true,
- });
- }, [
- api,
- currentlyPlaying?.item.Id,
- sessionData?.PlaySessionId,
- progressTicks,
- ]);
+ const playVideo = useCallback(
+ (triggerRef: boolean = true) => {
+ if (triggerRef === true) {
+ videoRef.current?.resume();
+ }
+ _setIsPlaying(true);
+ reportPlaybackProgress({
+ api,
+ itemId: currentlyPlaying?.item.Id,
+ positionTicks: progressTicks ? progressTicks : 0,
+ sessionId: session?.PlaySessionId,
+ IsPaused: false,
+ });
+ },
+ [api, currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks]
+ );
- const pauseVideo = useCallback(() => {
- videoRef.current?.pause();
- setIsPlaying(false);
- reportPlaybackProgress({
- api,
- itemId: currentlyPlaying?.item.Id,
- positionTicks: progressTicks ? progressTicks : 0,
- sessionId: sessionData?.PlaySessionId,
- IsPaused: false,
- });
- }, [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]);
+ const pauseVideo = useCallback(
+ (triggerRef: boolean = true) => {
+ if (triggerRef === true) {
+ videoRef.current?.pause();
+ }
+ _setIsPlaying(false);
+ reportPlaybackProgress({
+ api,
+ itemId: currentlyPlaying?.item.Id,
+ positionTicks: progressTicks ? progressTicks : 0,
+ sessionId: session?.PlaySessionId,
+ IsPaused: true,
+ });
+ },
+ [session?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]
+ );
const stopPlayback = useCallback(async () => {
await reportPlaybackStopped({
api,
itemId: currentlyPlaying?.item?.Id,
- sessionId: sessionData?.PlaySessionId,
+ sessionId: session?.PlaySessionId,
positionTicks: progressTicks ? progressTicks : 0,
});
setCurrentlyPlayingState(null);
- }, [currentlyPlaying, sessionData, progressTicks]);
+ }, [currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks, api]);
- const onProgress = useCallback(
+ const setIsPlaying = useCallback(
+ debounce((value: boolean) => {
+ _setIsPlaying(value);
+ }, 500),
+ []
+ );
+
+ const _onProgress = useCallback(
({ currentTime }: OnProgressData) => {
+ if (
+ !session?.PlaySessionId ||
+ !currentlyPlaying?.item.Id ||
+ currentTime === 0
+ )
+ return;
const ticks = currentTime * 10000000;
setProgressTicks(ticks);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: ticks,
- sessionId: sessionData?.PlaySessionId,
+ sessionId: session?.PlaySessionId,
IsPaused: !isPlaying,
});
},
- [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying]
+ [session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api]
+ );
+
+ const onProgress = useCallback(
+ debounce((e: OnProgressData) => {
+ _onProgress(e);
+ }, 1000),
+ [_onProgress]
);
const presentFullscreenPlayer = useCallback(() => {
@@ -184,7 +224,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => {
if (!deviceId || !api?.accessToken) return;
- const protocol = api?.basePath.includes('https') ? 'wss' : 'ws'
+ const protocol = api?.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api?.basePath
.replace("https://", "")
@@ -192,8 +232,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
api?.accessToken
}&deviceId=${deviceId}`;
- console.log(protocol, url);
-
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
@@ -204,7 +242,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
- console.log("KeepAlive message sent");
}
}, 30000);
};
@@ -215,7 +252,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
};
newWebSocket.onclose = (e) => {
- console.log("WebSocket connection closed:", e.reason);
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
@@ -238,6 +274,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
const json = JSON.parse(e.data);
const command = json?.Data?.Command;
+ console.log("[WS] ~ ", json);
+
// On PlayPause
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
@@ -246,6 +284,19 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
} else if (command === "Stop") {
console.log("Command ~ Stop");
stopPlayback();
+ } else if (command === "Mute") {
+ console.log("Command ~ Mute");
+ setVolume(0);
+ } else if (command === "Unmute") {
+ console.log("Command ~ Unmute");
+ setVolume(previousVolume.current || 20);
+ } else if (command === "SetVolume") {
+ console.log("Command ~ SetVolume");
+ } else if (json?.Data?.Name === "DisplayMessage") {
+ console.log("Command ~ DisplayMessage");
+ const title = json?.Data?.Arguments?.Header;
+ const body = json?.Data?.Arguments?.Text;
+ Alert.alert(title, body);
}
};
}, [ws, stopPlayback, playVideo, pauseVideo]);
@@ -255,12 +306,13 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
value={{
onProgress,
progressTicks,
+ setVolume,
setIsPlaying,
setIsFullscreen,
isFullscreen,
isPlaying,
currentlyPlaying,
- sessionData,
+ sessionData: session,
videoRef,
playVideo,
setCurrentlyPlayingState,
diff --git a/utils/atoms/orientation.ts b/utils/atoms/orientation.ts
new file mode 100644
index 00000000..19c40dad
--- /dev/null
+++ b/utils/atoms/orientation.ts
@@ -0,0 +1,7 @@
+import * as ScreenOrientation from "expo-screen-orientation";
+import { Orientation } from "expo-screen-orientation";
+import { atom } from "jotai";
+
+export const orientationAtom = atom(
+ ScreenOrientation.OrientationLock.PORTRAIT_UP
+);
diff --git a/utils/atoms/primaryColor.ts b/utils/atoms/primaryColor.ts
new file mode 100644
index 00000000..99dbc709
--- /dev/null
+++ b/utils/atoms/primaryColor.ts
@@ -0,0 +1,73 @@
+import { atom, useAtom } from "jotai";
+
+interface ThemeColors {
+ primary: string;
+ secondary: string;
+ average: string;
+ text: string;
+}
+
+const calculateTextColor = (backgroundColor: string): string => {
+ // Convert hex to RGB
+ const r = parseInt(backgroundColor.slice(1, 3), 16);
+ const g = parseInt(backgroundColor.slice(3, 5), 16);
+ const b = parseInt(backgroundColor.slice(5, 7), 16);
+
+ // Calculate perceived brightness
+ // Using the formula: (R * 299 + G * 587 + B * 114) / 1000
+ const brightness = (r * 299 + g * 587 + b * 114) / 1000;
+
+ // Calculate contrast ratio with white and black
+ const contrastWithWhite = calculateContrastRatio([255, 255, 255], [r, g, b]);
+ const contrastWithBlack = calculateContrastRatio([0, 0, 0], [r, g, b]);
+
+ // Use black text if the background is bright and has good contrast with black
+ if (brightness > 180 && contrastWithBlack >= 4.5) {
+ return "#000000";
+ }
+
+ // Otherwise, use white text
+ return "#FFFFFF";
+};
+
+// Helper function to calculate contrast ratio
+const calculateContrastRatio = (rgb1: number[], rgb2: number[]): number => {
+ const l1 = calculateRelativeLuminance(rgb1);
+ const l2 = calculateRelativeLuminance(rgb2);
+ const lighter = Math.max(l1, l2);
+ const darker = Math.min(l1, l2);
+ return (lighter + 0.05) / (darker + 0.05);
+};
+
+// Helper function to calculate relative luminance
+const calculateRelativeLuminance = (rgb: number[]): number => {
+ const [r, g, b] = rgb.map((c) => {
+ c /= 255;
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+};
+
+const baseThemeColorAtom = atom({
+ primary: "#FFFFFF",
+ secondary: "#000000",
+ average: "#888888",
+ text: "#000000",
+});
+
+export const itemThemeColorAtom = atom(
+ (get) => get(baseThemeColorAtom),
+ (get, set, update: Partial) => {
+ const currentColors = get(baseThemeColorAtom);
+ const newColors = { ...currentColors, ...update };
+
+ // Recalculate text color if primary color changes
+ if (update.average) {
+ newColors.text = calculateTextColor(update.average);
+ }
+
+ set(baseThemeColorAtom, newColors);
+ }
+);
+
+export const useItemThemeColor = () => useAtom(itemThemeColorAtom);
diff --git a/utils/jellyfin/image/getBackdropUrl.ts b/utils/jellyfin/image/getBackdropUrl.ts
index 7fdf7efe..dd138aab 100644
--- a/utils/jellyfin/image/getBackdropUrl.ts
+++ b/utils/jellyfin/image/getBackdropUrl.ts
@@ -36,6 +36,10 @@ export const getBackdropUrl = ({
params.append("fillWidth", width.toString());
}
+ if (item.Type === "Episode") {
+ return getPrimaryImageUrl({ api, item, quality, width });
+ }
+
if (backdropImageTags) {
params.append("tag", backdropImageTags);
return `${api.basePath}/Items/${
diff --git a/utils/jellyfin/image/getLogoImageUrlById.ts b/utils/jellyfin/image/getLogoImageUrlById.ts
index a801b1b7..3712b888 100644
--- a/utils/jellyfin/image/getLogoImageUrlById.ts
+++ b/utils/jellyfin/image/getLogoImageUrlById.ts
@@ -21,15 +21,29 @@ export const getLogoImageUrlById = ({
return null;
}
- const imageTags = item.ImageTags?.["Logo"];
-
- if (!imageTags) return null;
-
const params = new URLSearchParams();
- params.append("tag", imageTags);
params.append("quality", "90");
params.append("fillHeight", height.toString());
+ if (item.Type === "Episode") {
+ const imageTag = item.ParentLogoImageTag;
+ const parentId = item.ParentLogoItemId;
+
+ if (!parentId || !imageTag) {
+ return null;
+ }
+
+ params.append("tag", imageTag);
+
+ return `${api.basePath}/Items/${parentId}/Images/Logo?${params.toString()}`;
+ }
+
+ const imageTag = item.ImageTags?.["Logo"];
+
+ if (!imageTag) return null;
+
+ params.append("tag", imageTag);
+
return `${api.basePath}/Items/${item.Id}/Images/Logo?${params.toString()}`;
};
diff --git a/utils/jellyfin/image/getParentBackdropImageUrl.ts b/utils/jellyfin/image/getParentBackdropImageUrl.ts
new file mode 100644
index 00000000..4a03795b
--- /dev/null
+++ b/utils/jellyfin/image/getParentBackdropImageUrl.ts
@@ -0,0 +1,42 @@
+import { Api } from "@jellyfin/sdk";
+import {
+ BaseItemDto,
+ BaseItemPerson,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { isBaseItemDto } from "../jellyfin";
+
+/**
+ * Retrieves the primary image URL for a given item.
+ *
+ * @param api - The Jellyfin API instance.
+ * @param item - The media item to retrieve the backdrop image URL for.
+ * @param quality - The desired image quality (default: 90).
+ */
+export const getParentBackdropImageUrl = ({
+ api,
+ item,
+ quality = 80,
+ width = 400,
+}: {
+ api?: Api | null;
+ item?: BaseItemDto | null;
+ quality?: number | null;
+ width?: number | null;
+}) => {
+ if (!item || !api) {
+ return null;
+ }
+
+ const parentId = item.ParentBackdropItemId;
+ const tag = item.ParentBackdropImageTags?.[0] || "";
+
+ const params = new URLSearchParams({
+ fillWidth: width ? String(width) : "500",
+ quality: quality ? String(quality) : "80",
+ tag: tag,
+ });
+
+ return `${
+ api?.basePath
+ }/Items/${parentId}/Images/Backdrop/0?${params.toString()}`;
+};
diff --git a/utils/jellyfin/image/getPrimaryParentImageUrl.ts b/utils/jellyfin/image/getPrimaryParentImageUrl.ts
new file mode 100644
index 00000000..ff862624
--- /dev/null
+++ b/utils/jellyfin/image/getPrimaryParentImageUrl.ts
@@ -0,0 +1,42 @@
+import { Api } from "@jellyfin/sdk";
+import {
+ BaseItemDto,
+ BaseItemPerson,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { isBaseItemDto } from "../jellyfin";
+
+/**
+ * Retrieves the primary image URL for a given item.
+ *
+ * @param api - The Jellyfin API instance.
+ * @param item - The media item to retrieve the backdrop image URL for.
+ * @param quality - The desired image quality (default: 90).
+ */
+export const getPrimaryParentImageUrl = ({
+ api,
+ item,
+ quality = 80,
+ width = 400,
+}: {
+ api?: Api | null;
+ item?: BaseItemDto | null;
+ quality?: number | null;
+ width?: number | null;
+}) => {
+ if (!item || !api) {
+ return null;
+ }
+
+ const parentId = item.ParentId;
+ const primaryTag = item.ParentPrimaryImageTag?.[0];
+
+ const params = new URLSearchParams({
+ fillWidth: width ? String(width) : "500",
+ quality: quality ? String(quality) : "80",
+ tag: primaryTag || "",
+ });
+
+ return `${
+ api?.basePath
+ }/Items/${parentId}/Images/Primary?${params.toString()}`;
+};
diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts
index 6c5a6056..28b47f8e 100644
--- a/utils/jellyfin/media/getStreamUrl.ts
+++ b/utils/jellyfin/media/getStreamUrl.ts
@@ -18,6 +18,7 @@ export const getStreamUrl = async ({
subtitleStreamIndex = 0,
forceDirectPlay = false,
height,
+ mediaSourceId,
}: {
api: Api | null | undefined;
item: BaseItemDto | null | undefined;
@@ -30,8 +31,9 @@ export const getStreamUrl = async ({
subtitleStreamIndex?: number;
forceDirectPlay?: boolean;
height?: number;
+ mediaSourceId: string | null;
}) => {
- if (!api || !userId || !item?.Id) {
+ if (!api || !userId || !item?.Id || !mediaSourceId) {
return null;
}
@@ -46,7 +48,7 @@ export const getStreamUrl = async ({
StartTimeTicks: startTimeTicks,
EnableTranscoding: maxStreamingBitrate ? true : undefined,
AutoOpenLiveStream: true,
- MediaSourceId: itemId,
+ MediaSourceId: mediaSourceId,
AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
AudioStreamIndex: audioStreamIndex,
SubtitleStreamIndex: subtitleStreamIndex,
@@ -62,7 +64,9 @@ export const getStreamUrl = async ({
}
);
- const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
+ const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
+ (source: MediaSourceInfo) => source.Id === mediaSourceId
+ );
if (!mediaSource) {
throw new Error("No media source");
@@ -72,10 +76,12 @@ export const getStreamUrl = async ({
throw new Error("no PlaySessionId");
}
+ let url: string | null | undefined;
+
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
if (item.MediaType === "Video") {
console.log("Using direct stream for video!");
- return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
+ 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") {
console.log("Using direct stream for audio!");
const searchParams = new URLSearchParams({
@@ -93,16 +99,16 @@ export const getStreamUrl = async ({
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
- return `${
+ url = `${
api.basePath
}/Audio/${itemId}/universal?${searchParams.toString()}`;
}
+ } else if (mediaSource.TranscodingUrl) {
+ console.log("Using transcoded stream!");
+ url = `${api.basePath}${mediaSource.TranscodingUrl}`;
}
- if (mediaSource.TranscodingUrl) {
- console.log("Using transcoded stream!");
- return `${api.basePath}${mediaSource.TranscodingUrl}`;
- } else {
- throw new Error("No transcoding url");
- }
+ if (!url) throw new Error("No url");
+
+ return url;
};
diff --git a/utils/jellyfin/playstate/reportPlaybackProgress.ts b/utils/jellyfin/playstate/reportPlaybackProgress.ts
index 6e89a962..45e71ec6 100644
--- a/utils/jellyfin/playstate/reportPlaybackProgress.ts
+++ b/utils/jellyfin/playstate/reportPlaybackProgress.ts
@@ -24,12 +24,6 @@ export const reportPlaybackProgress = async ({
IsPaused = false,
}: ReportPlaybackProgressParams): Promise => {
if (!api || !sessionId || !itemId || !positionTicks) {
- console.error(
- "Missing required parameter",
- sessionId,
- itemId,
- positionTicks
- );
return;
}
diff --git a/utils/jellyfin/playstate/reportPlaybackStopped.ts b/utils/jellyfin/playstate/reportPlaybackStopped.ts
index eb7278ef..665eb032 100644
--- a/utils/jellyfin/playstate/reportPlaybackStopped.ts
+++ b/utils/jellyfin/playstate/reportPlaybackStopped.ts
@@ -41,8 +41,6 @@ export const reportPlaybackStopped = async ({
return;
}
- console.log("reportPlaybackStopped ~", { sessionId, itemId });
-
try {
const url = `${api.basePath}/PlayingItems/${itemId}`;
const params = {
diff --git a/utils/jellyfin/session/capabilities.ts b/utils/jellyfin/session/capabilities.ts
index 7149adef..ccb068c2 100644
--- a/utils/jellyfin/session/capabilities.ts
+++ b/utils/jellyfin/session/capabilities.ts
@@ -4,7 +4,7 @@ import {
SessionApiPostCapabilitiesRequest,
} from "@jellyfin/sdk/lib/generated-client/api/session-api";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
-import { AxiosError } from "axios";
+import { AxiosError, AxiosResponse } from "axios";
import { getAuthHeaders } from "../jellyfin";
interface PostCapabilitiesParams {
@@ -23,17 +23,26 @@ export const postCapabilities = async ({
api,
itemId,
sessionId,
-}: PostCapabilitiesParams): Promise => {
+}: PostCapabilitiesParams): Promise => {
if (!api || !itemId || !sessionId) {
- throw new Error("Missing required parameters");
+ throw new Error("Missing parameters for marking item as not played");
}
try {
- const r = await api.axiosInstance.post(
+ const d = api.axiosInstance.post(
api.basePath + "/Sessions/Capabilities/Full",
{
playableMediaTypes: ["Audio", "Video", "Audio"],
- supportedCommands: ["PlayState", "Play"],
+ supportedCommands: [
+ "PlayState",
+ "Play",
+ "ToggleFullscreen",
+ "DisplayMessage",
+ "Mute",
+ "Unmute",
+ "SetVolume",
+ "ToggleMute",
+ ],
supportsMediaControl: true,
id: sessionId,
},
@@ -41,8 +50,8 @@ export const postCapabilities = async ({
headers: getAuthHeaders(api),
}
);
+ return d;
} catch (error: any | AxiosError) {
- console.log("Failed to mark as not played", error);
throw new Error("Failed to mark as not played");
}
};