Compare commits

..

1 Commits

Author SHA1 Message Date
Fredrik Burmester
ceb9969007 wip 2024-08-23 09:09:33 +02:00
107 changed files with 2768 additions and 5373 deletions

1
.gitignore vendored
View File

@@ -21,7 +21,6 @@ build-*
*.mp4 *.mp4
build-* build-*
Streamyfin.app Streamyfin.app
package-lock.json
/ios /ios
/android /android

View File

@@ -1,14 +1,13 @@
# 📺 Streamyfin # 📺 Streamyfin
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
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. 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.
<div style="display: flex; flex-direction: row; gap: 8px"> <div style="display: flex; flex-direction: row; gap: 5px">
<img width=150 src="./assets/images/screenshots/screenshot1.png" /> <img width=100 src="./assets/images/screenshots/1.jpg" />
<img width=150 src="./assets/images/screenshots/screenshot3.png" /> <img width=100 src="./assets/images/screenshots/3.jpg" />
<img width=150 src="./assets/images/screenshots/screenshot2.png" /> <img width=100 src="./assets/images/screenshots/4.jpg" />
<img width=100 src="./assets/images/screenshots/5.jpg" />
<img width=100 src="./assets/images/screenshots/7.jpg" />
</div> </div>
## 🌟 Features ## 🌟 Features
@@ -25,7 +24,7 @@ Streamyfin includes some exciting experimental features like media downloading a
### Downloading ### Downloading
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode. Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
### Chromecast ### Chromecast
@@ -33,19 +32,19 @@ Chromecast support is still in development, and we're working on improving it. C
## Plugins ## Plugins
In Streamyfin we have built-in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality. In Streamyfin we have build in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality.
### Collection rows ### Collection rows
Jellyfin collections can be shown as rows or carousel on the home screen. Jellyfin collections can be shown as rows or carousel on the home screen.
The following tags can be added to a collection to provide this functionality. The following tags can be added to an collection to provide this functionality.
Available tags: Avaiable tags:
- sf_promoted: will make the collection a row at home - sf_promoted: Wil make the collection an row on home
- sf_carousel: will make the collection a carousel on home. - sf_carousel: Wil make the collection an carousel on home.
A plugin exists to create collections based on external sources like mdblist. This make the automatic process of managing collections such as trending, most watched, etc. A plugin exists to create collections based on external sources like mdblist. This makes managing collections like trending, most watched etc an automatic process.
See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info. See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info.
### Jellysearch ### Jellysearch
@@ -89,8 +88,8 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
### Development info ### Development info
1. Use node `20` 1. Use node `20`
2. Install dependencies `bun i` 2. Install deps `bun i`
3. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. 3. `Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
## Extended chromecast controls ## Extended chromecast controls
@@ -142,6 +141,10 @@ If you have questions or need support, feel free to reach out:
- GitHub Issues: Report bugs or request features here. - GitHub Issues: Report bugs or request features here.
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com) - Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
## Support
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
## 📝 Credits ## 📝 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. 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.

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.12.0", "version": "0.8.2",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -25,15 +25,12 @@
"NSAllowsArbitraryLoads": true "NSAllowsArbitraryLoads": true
} }
}, },
"config": {
"usesNonExemptEncryption": false
},
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin" "bundleIdentifier": "com.fredrikburmester.streamyfin"
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 36, "versionCode": 23,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png" "foregroundImage": "./assets/images/icon.png"
}, },
@@ -71,13 +68,6 @@
} }
} }
], ],
[
"./plugins/withAndroidMainActivityAttributes",
{
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
}
],
["./plugins/withExpandedController.js"],
[ [
"expo-build-properties", "expo-build-properties",
{ {
@@ -85,11 +75,6 @@
"deploymentTarget": "14.0" "deploymentTarget": "14.0"
}, },
"android": { "android": {
"android": {
"compileSdkVersion": 34,
"targetSdkVersion": 34,
"buildToolsVersion": "34.0.0"
},
"minSdkVersion": 24, "minSdkVersion": 24,
"usesCleartextTraffic": true, "usesCleartextTraffic": true,
"packagingOptions": { "packagingOptions": {

View File

@@ -1,406 +0,0 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
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 { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
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<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = BaseSection & {
type: "MediaListSection";
queryFn: () => Promise<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() {
const queryClient = useQueryClient();
const router = useRouter();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [loading, setLoading] = useState(false);
const [settings, _] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
return () => {
unsubscribe();
};
}, []);
const {
data: userViews,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const {
data: mediaListCollections,
isError: e2,
isLoading: l2,
} = useQuery({
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
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"] });
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
await queryClient.refetchQueries({
queryKey: ["sf_promoted"],
});
await queryClient.refetchQueries({
queryKey: ["sf_carousel"],
});
setLoading(false);
}, [queryClient, user?.Id]);
const sections = useMemo(() => {
if (!api || !user?.Id) return [];
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"],
includeItemTypes: ["Movie"],
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"],
includeItemTypes: ["Series"],
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 () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id)
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [
api,
user?.Id,
movieCollectionId,
tvShowCollectionId,
mediaListCollections,
]);
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">No Internet</Text>
<Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content.
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
Go to downloads
</Button>
<Button
color="black"
onPress={() => {
checkConnection();
}}
justify="center"
className="mt-2"
iconRight={
loadingRetry ? null : (
<Ionicons name="refresh" size={20} color="white" />
)
}
>
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"
)}
</Button>
</View>
</View>
);
}
const insets = useSafeAreaInsets();
if (e1 || e2)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
<Text className="text-center opacity-70">
Something went wrong.{"\n"}Please log out and in again.
</Text>
</View>
);
if (l1 || l2)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
>
<View
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
className="flex flex-col pt-4 pb-24 gap-y-4"
>
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
}
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
}

View File

@@ -1,145 +0,0 @@
import { Text } from "@/components/common/Text";
import { List } from "@/components/List";
import { ListItem } from "@/components/ListItem";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { ActivityIndicator, Alert, ScrollView, View } from "react-native";
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
const name = useMemo(() => user?.Name || "", [user]);
const { data: activeGroups } = useQuery({
queryKey: ["syncplay", "activeGroups"],
queryFn: async () => {
if (!api) return [];
const res = await getSyncPlayApi(api).syncPlayGetGroups();
return res.data;
},
refetchInterval: 1000,
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchOnMount: true,
});
const createGroupMutation = useMutation({
mutationFn: async (GroupName: string) => {
if (!api) return;
const res = await getSyncPlayApi(api).syncPlayCreateGroup({
newGroupRequestDto: {
GroupName,
},
});
if (res.status !== 204) {
Alert.alert("Error", "Failed to create group");
return false;
}
return true;
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
},
});
const createGroup = () => {
Alert.prompt("Create Group", "Enter a name for the group", (text) => {
if (text) {
createGroupMutation.mutate(text);
}
});
};
const joinGroupMutation = useMutation({
mutationFn: async (groupId: string) => {
if (!api) return;
const res = await getSyncPlayApi(api).syncPlayJoinGroup({
joinGroupRequestDto: {
GroupId: groupId,
},
});
if (res.status !== 204) {
Alert.alert("Error", "Failed to join group");
return false;
}
return true;
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
},
});
const leaveGroupMutation = useMutation({
mutationFn: async () => {
if (!api) return;
const res = await getSyncPlayApi(api).syncPlayLeaveGroup();
if (res.status !== 204) {
Alert.alert("Error", "Failed to exit group");
return false;
}
return true;
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
},
});
return (
<ScrollView>
<View className="px-4 py-4">
<View>
<Text className="text-lg font-bold mb-4">Join group</Text>
{!activeGroups?.length && (
<Text className="text-neutral-500 mb-4">No active groups</Text>
)}
<List>
{activeGroups?.map((group) => (
<ListItem
key={group.GroupId}
title={group.GroupName}
onPress={async () => {
if (!group.GroupId) {
return;
}
if (group.Participants?.includes(name)) {
leaveGroupMutation.mutate();
} else {
joinGroupMutation.mutate(group.GroupId);
}
}}
iconAfter={
group.Participants?.includes(name) ? (
<Ionicons name="exit-outline" size={20} color="white" />
) : (
<Ionicons name="add" size={20} color="white" />
)
}
subTitle={group.Participants?.join(", ")}
/>
))}
<ListItem
onPress={() => createGroup()}
key={"create"}
title={"Create group"}
iconAfter={
createGroupMutation.isPending ? (
<ActivityIndicator size={20} color={"white"} />
) : (
<Ionicons name="add" size={20} color="white" />
)
}
/>
</List>
</View>
</View>
</ScrollView>
);
}

View File

@@ -1,13 +0,0 @@
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(() => <ItemContent id={id} />, [id]);
return memoizedContent;
};
export default React.memo(Page);

View File

@@ -1,200 +0,0 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router";
import { Platform } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
export default function IndexLayout() {
const [settings, updateSettings] = useSettings();
if (!settings?.libraryOptions) return null;
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Library",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerRight: () => (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Ionicons
name="ellipsis-horizontal-outline"
size={24}
color="white"
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content
align={"end"}
alignOffset={-10}
avoidCollisions={false}
collisionPadding={0}
loop={false}
side={"bottom"}
sideOffset={10}
>
<DropdownMenu.Label>Display</DropdownMenu.Label>
<DropdownMenu.Group key="display-group">
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
Display
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key="display-option-1"
value={settings.libraryOptions.display === "row"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "row",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-1">
Row
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="display-option-2"
value={settings.libraryOptions.display === "list"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "list",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-2">
List
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
Image style
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key="poster-option"
value={settings.libraryOptions.imageStyle === "poster"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "poster",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="poster-title">
Poster
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="cover-option"
value={settings.libraryOptions.imageStyle === "cover"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "cover",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="cover-title">
Cover
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Group>
<DropdownMenu.Group key="show-titles-group">
<DropdownMenu.CheckboxItem
disabled={settings.libraryOptions.imageStyle === "poster"}
key="show-titles-option"
value={settings.libraryOptions.showTitles}
onValueChange={(newValue) => {
if (settings.libraryOptions.imageStyle === "poster")
return;
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: newValue === "on" ? true : false,
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-titles-title">
Show titles
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="show-stats-option"
value={settings.libraryOptions.showStats}
onValueChange={(newValue) => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: newValue === "on" ? true : false,
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-stats-title">
Show stats
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.Group>
<DropdownMenu.Separator />
</DropdownMenu.Content>
</DropdownMenu.Root>
),
}}
/>
<Stack.Screen
name="[libraryId]"
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
</Stack>
);
}

View File

@@ -1,102 +0,0 @@
import { Text } from "@/components/common/Text";
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
import { Loader } from "@/components/Loader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useEffect } from "react";
import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function index() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
const [settings] = useSettings();
const { data, isLoading: isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000 * 60,
});
useEffect(() => {
for (const item of data || []) {
queryClient.prefetchQuery({
queryKey: ["library", item.Id],
queryFn: async () => {
if (!item.Id || !user?.Id || !api) return null;
const response = await getUserLibraryApi(api).getItem({
itemId: item.Id,
userId: user?.Id,
});
return response.data;
},
staleTime: 60 * 1000,
});
}
}, [data]);
const insets = useSafeAreaInsets();
if (isLoading)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
if (!data)
return (
<View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">No libraries found</Text>
</View>
);
return (
<FlashList
extraData={settings}
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
paddingTop: 17,
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
paddingBottom: 150,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
data={data}
renderItem={({ item }) => <LibraryItemCard library={item} />}
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() =>
settings?.libraryOptions?.display === "row" ? (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className="bg-neutral-800 mx-2 my-4"
></View>
) : (
<View className="h-4" />
)
}
estimatedItemSize={200}
/>
);
}

View File

@@ -1,10 +1,13 @@
import { router, Tabs } from "expo-router";
import React, { useEffect } from "react";
import * as NavigationBar from "expo-navigation-bar";
import { TabBarIcon } from "@/components/navigation/TabBarIcon"; import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { Platform, TouchableOpacity, View } from "react-native";
import { Feather } from "@expo/vector-icons";
import { Chromecast } from "@/components/Chromecast";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import * as NavigationBar from "expo-navigation-bar"; import { StyleSheet } from "react-native";
import { Tabs } from "expo-router";
import React, { useEffect } from "react";
import { Platform, StyleSheet } from "react-native";
export default function TabLayout() { export default function TabLayout() {
useEffect(() => { useEffect(() => {
@@ -47,7 +50,7 @@ export default function TabLayout() {
> >
<Tabs.Screen redirect name="index" /> <Tabs.Screen redirect name="index" />
<Tabs.Screen <Tabs.Screen
name="(home)" name="home"
options={{ options={{
headerShown: false, headerShown: false,
title: "Home", title: "Home",
@@ -60,7 +63,7 @@ export default function TabLayout() {
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="(search)" name="search"
options={{ options={{
headerShown: false, headerShown: false,
title: "Search", title: "Search",
@@ -70,7 +73,7 @@ export default function TabLayout() {
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="(libraries)" name="libraries"
options={{ options={{
headerShown: false, headerShown: false,
title: "Library", title: "Library",

View File

@@ -1,9 +1,8 @@
import { Chromecast } from "@/components/Chromecast"; import { Chromecast } from "@/components/Chromecast";
import { HeaderBackButton } from "@/components/common/HeaderBackButton"; import { Feather } from "@expo/vector-icons";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, View } from "react-native";
import { TouchableOpacity } from "react-native";
export default function IndexLayout() { export default function IndexLayout() {
const router = useRouter(); const router = useRouter();
@@ -32,16 +31,6 @@ export default function IndexLayout() {
), ),
headerRight: () => ( headerRight: () => (
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
router.push("/(auth)/syncplay");
}}
style={{
marginRight: 8,
}}
>
<Ionicons name="people" color={"white"} size={22} />
</TouchableOpacity>
<Chromecast /> <Chromecast />
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
@@ -56,28 +45,6 @@ export default function IndexLayout() {
), ),
}} }}
/> />
<Stack.Screen
name="downloads"
options={{
title: "Downloads",
}}
/>
<Stack.Screen
name="settings"
options={{
title: "Settings",
}}
/>
<Stack.Screen
name="syncplay"
options={{
title: "Syncplay",
presentation: "modal",
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
</Stack> </Stack>
); );
} }

View File

@@ -0,0 +1,299 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
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,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
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";
export default function index() {
const router = useRouter();
const queryClient = useQueryClient();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [loading, setLoading] = useState(false);
const [settings, _] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
return () => {
unsubscribe();
};
}, []);
const { data, isLoading, isError } = useQuery<BaseItemDto[]>({
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],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
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<BaseItemDto[]>({
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<BaseItemDto[]>({
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({
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
const refetch = useCallback(async () => {
setLoading(true);
await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
await queryClient.refetchQueries({
queryKey: ["sf_promoted"],
});
await queryClient.refetchQueries({
queryKey: ["sf_carousel"],
});
setLoading(false);
}, [queryClient, user?.Id]);
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">No Internet</Text>
<Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content.
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
Go to downloads
</Button>
</View>
</View>
);
}
if (isError)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
<Text className="text-center opacity-70">
Something went wrong.{"\n"}Please log out and in again.
</Text>
</View>
);
if (isLoading)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
>
<View className="flex flex-col pt-4 pb-24 gap-y-4">
<LargeMovieCarousel />
<ScrollingCollectionList
title="Continue Watching"
data={data}
loading={isLoading}
orientation="horizontal"
/>
<ScrollingCollectionList
title="Next Up"
data={nextUpData}
loading={isLoadingNextUp}
orientation="horizontal"
/>
{mediaListCollections?.map((ml) => (
<MediaListSection key={ml.Id} collection={ml} />
))}
<ScrollingCollectionList
title="Recently Added in Movies"
data={recentlyAddedInMovies}
loading={isLoadingRecentlyAddedMovies}
/>
<ScrollingCollectionList
title="Recently Added in TV-Shows"
data={recentlyAddedInTVShows}
loading={isLoadingRecentlyAddedTVShows}
/>
<ScrollingCollectionList
title="Suggestions"
data={suggestions}
loading={isLoadingSuggestions}
orientation="horizontal"
/>
</View>
</ScrollView>
);
}

View File

@@ -1,37 +1,36 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useFocusEffect, useLocalSearchParams } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react"; import React, {
import { FlatList, useWindowDimensions, View } from "react-native"; useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import { FlatList, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import MoviePoster from "@/components/posters/MoviePoster";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import {
genreFilterAtom, genreFilterAtom,
getSortByPreference,
getSortOrderPreference,
sortByAtom, sortByAtom,
SortByOption,
sortByPreferenceAtom,
sortOptions, sortOptions,
sortOrderAtom, sortOrderAtom,
SortOrderOption,
sortOrderOptions, sortOrderOptions,
sortOrderPreferenceAtom,
tagsFilterAtom, tagsFilterAtom,
yearFilterAtom, yearFilterAtom,
} from "@/utils/atoms/filters"; } from "@/utils/atoms/filters";
import { orientationAtom } from "@/utils/atoms/orientation";
import { import {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { import {
getFilterApi, getFilterApi,
@@ -39,7 +38,6 @@ import {
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter); const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
@@ -49,72 +47,50 @@ const Page = () => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { width: screenWidth } = useWindowDimensions(); const navigation = useNavigation();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, _setSortBy] = useAtom(sortByAtom); const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom); const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const [orientation] = useAtom(orientationAtom);
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom); const [orientation, setOrientation] = useState(
const [sortOrderPreference, setOderByPreference] = useAtom( ScreenOrientation.Orientation.PORTRAIT_UP
sortOrderPreferenceAtom
); );
useEffect(() => { useLayoutEffect(() => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference); setSortBy([
if (sop) { {
console.log("getSortOrderPreference ~", sop, libraryId); key: "SortName",
_setSortOrder([sop]); value: "Name",
} else { },
_setSortOrder([SortOrderOption.Ascending]); ]);
} setSortOrder([
const obp = getSortByPreference(libraryId, sortByPreference); {
console.log("getSortByPreference ~", obp, libraryId); key: "Ascending",
if (obp) { value: "Ascending",
_setSortBy([obp]); },
} else { ]);
_setSortBy([SortByOption.SortName]);
}
}, []); }, []);
const setSortBy = useCallback( useEffect(() => {
(sortBy: SortByOption[]) => { const subscription = ScreenOrientation.addOrientationChangeListener(
const sop = getSortByPreference(libraryId, sortByPreference); (event) => {
if (sortBy[0] !== sop) { setOrientation(event.orientationInfo.orientation);
console.log("setSortByPreference ~", sortBy[0], libraryId);
setSortByPreference({ ...sortByPreference, [libraryId]: sortBy[0] });
} }
_setSortBy(sortBy); );
},
[libraryId, sortByPreference]
);
const setSortOrder = useCallback( ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
(sortOrder: SortOrderOption[]) => { setOrientation(initialOrientation);
const sop = getSortOrderPreference(libraryId, sortOrderPreference); });
if (sortOrder[0] !== sop) {
console.log("setSortOrderPreference ~", sortOrder[0], libraryId);
setOderByPreference({
...sortOrderPreference,
[libraryId]: sortOrder[0],
});
}
_setSortOrder(sortOrder);
},
[libraryId, sortOrderPreference]
);
const getNumberOfColumns = useCallback(() => { return () => {
if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3; ScreenOrientation.removeOrientationChangeListener(subscription);
if (screenWidth < 600) return 5; };
if (screenWidth < 960) return 6; }, []);
if (screenWidth < 1280) return 7;
return 6;
}, [screenWidth, orientation]);
const { data: library, isLoading: isLibraryLoading } = useQuery({ const { data: library } = useQuery({
queryKey: ["library", libraryId], queryKey: ["library", libraryId],
queryFn: async () => { queryFn: async () => {
if (!api) return null; if (!api) return null;
@@ -125,7 +101,7 @@ const Page = () => {
return response.data; return response.data;
}, },
enabled: !!api && !!user?.Id && !!libraryId, enabled: !!api && !!user?.Id && !!libraryId,
staleTime: 60 * 1000, staleTime: 0,
}); });
const fetchItems = useCallback( const fetchItems = useCallback(
@@ -136,15 +112,36 @@ const Page = () => {
}): Promise<BaseItemDtoQueryResult | null> => { }): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !library) return null; if (!api || !library) return null;
let includeItemTypes: BaseItemKind[] | undefined = [];
switch (library?.CollectionType) {
case "movies":
includeItemTypes.push("Movie");
break;
case "boxsets":
includeItemTypes.push("BoxSet");
break;
case "tvshows":
includeItemTypes.push("Series");
break;
case "music":
includeItemTypes.push("MusicAlbum");
break;
default:
includeItemTypes = undefined;
break;
}
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
userId: user?.Id, userId: user?.Id,
parentId: libraryId, parentId: libraryId,
limit: 36, limit: 20,
startIndex: pageParam, startIndex: pageParam,
sortBy: [sortBy[0], "SortName", "ProductionYear"], sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
sortOrder: [sortOrder[0]], sortOrder: [sortOrder[0].key],
includeItemTypes,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
recursive: false, recursive: true,
imageTypeLimit: 1, imageTypeLimit: 1,
fields: ["PrimaryImageAspectRatio", "SortName"], fields: ["PrimaryImageAspectRatio", "SortName"],
genres: selectedGenres, genres: selectedGenres,
@@ -167,41 +164,40 @@ const Page = () => {
] ]
); );
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
useInfiniteQuery({ queryKey: [
queryKey: [ "library-items",
"library-items", libraryId,
libraryId, selectedGenres,
selectedGenres, selectedYears,
selectedYears, selectedTags,
selectedTags, sortBy,
sortBy, sortOrder,
sortOrder, ],
], queryFn: fetchItems,
queryFn: fetchItems, getNextPageParam: (lastPage, pages) => {
getNextPageParam: (lastPage, pages) => { if (
if ( !lastPage?.Items ||
!lastPage?.Items || !lastPage?.TotalRecordCount ||
!lastPage?.TotalRecordCount || lastPage?.TotalRecordCount === 0
lastPage?.TotalRecordCount === 0 )
) return undefined;
return undefined;
const totalItems = lastPage.TotalRecordCount; const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce( const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0), (acc, curr) => acc + (curr?.Items?.length || 0),
0 0
); );
if (accumulatedItems < totalItems) { if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length; return lastPage?.Items?.length * pages.length;
} else { } else {
return undefined; return undefined;
} }
}, },
initialPageParam: 0, initialPageParam: 0,
enabled: !!api && !!user?.Id && !!library, enabled: !!api && !!user?.Id && !!library,
}); });
const flatData = useMemo(() => { const flatData = useMemo(() => {
return ( return (
@@ -216,25 +212,23 @@ const Page = () => {
key={item.Id} key={item.Id}
style={{ style={{
width: "100%", width: "100%",
marginBottom: 4, marginBottom:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
}} }}
item={item} item={item}
> >
<View <View
style={{ style={{
alignSelf: alignSelf:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP index % 3 === 0
? index % 3 === 0 ? "flex-end"
? "flex-end" : (index + 1) % 3 === 0
: (index + 1) % 3 === 0 ? "flex-start"
? "flex-start"
: "center"
: "center", : "center",
width: "89%", width: "89%",
}} }}
> >
{/* <MoviePoster item={item} /> */} <MoviePoster item={item} />
<ItemPoster item={item} />
<ItemCardText item={item} /> <ItemCardText item={item} />
</View> </View>
</MemoizedTouchableItemRouter> </MemoizedTouchableItemRouter>
@@ -347,15 +341,13 @@ const Page = () => {
className="mr-1" className="mr-1"
collectionId={libraryId} collectionId={libraryId}
queryKey="sortBy" queryKey="sortBy"
queryFn={async () => sortOptions.map((s) => s.key)} queryFn={async () => sortOptions}
set={setSortBy} set={setSortBy}
values={sortBy} values={sortBy}
title="Sort By" title="Sort By"
renderItemLabel={(item) => renderItemLabel={(item) => item.value}
sortOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.value.toLowerCase().includes(search.toLowerCase())
} }
/> />
), ),
@@ -367,15 +359,13 @@ const Page = () => {
className="mr-1" className="mr-1"
collectionId={libraryId} collectionId={libraryId}
queryKey="sortOrder" queryKey="sortOrder"
queryFn={async () => sortOrderOptions.map((s) => s.key)} queryFn={async () => sortOrderOptions}
set={setSortOrder} set={setSortOrder}
values={sortOrder} values={sortOrder}
title="Sort Order" title="Sort Order"
renderItemLabel={(item) => renderItemLabel={(item) => item.value}
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.value.toLowerCase().includes(search.toLowerCase())
} }
/> />
), ),
@@ -404,21 +394,7 @@ const Page = () => {
] ]
); );
const insets = useSafeAreaInsets(); if (!library) return null;
if (isLoading || isLibraryLoading)
return (
<View className="w-full h-full flex items-center justify-center">
<Loader />
</View>
);
if (flatData.length === 0)
return (
<View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">No items found</Text>
</View>
);
return ( return (
<FlashList <FlashList
@@ -430,22 +406,19 @@ const Page = () => {
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
data={flatData} data={flatData}
renderItem={renderItem} renderItem={renderItem}
extraData={orientation}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
estimatedItemSize={244} estimatedItemSize={255}
numColumns={getNumberOfColumns()} numColumns={
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
}
onEndReached={() => { onEndReached={() => {
if (hasNextPage) { if (hasNextPage) {
fetchNextPage(); fetchNextPage();
} }
}} }}
onEndReachedThreshold={1} onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent} ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ contentContainerStyle={{ paddingBottom: 24 }}
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => ( ItemSeparatorComponent={() => (
<View <View
style={{ style={{

View File

@@ -0,0 +1,30 @@
import { Stack, useRouter } from "expo-router";
import { Platform } from "react-native";
export default function IndexLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Library",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="[libraryId]"
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,104 @@
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
export default function index() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data, isLoading: isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
if (isLoading)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<FlashList
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
paddingTop: 17,
paddingHorizontal: 17,
paddingBottom: 150,
}}
data={data}
renderItem={({ item }) => <LibraryItemCard library={item} />}
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() => <View className="h-4" />}
estimatedItemSize={200}
/>
);
}
interface Props {
library: BaseItemDto;
}
const LibraryItemCard: React.FC<Props> = ({ library }) => {
const router = useRouter();
const [api] = useAtom(apiAtom);
const url = useMemo(
() =>
getPrimaryImageUrl({
api,
item: library,
}),
[library]
);
if (!url) return null;
return (
<TouchableOpacity
onPress={() => {
router.push(`/libraries/${library.Id}`);
}}
>
<View className="flex justify-center rounded-xl w-full relative border border-neutral-900 h-20 ">
<Image
source={{ uri: url }}
style={{
width: "100%",
height: "100%",
borderRadius: 8,
position: "absolute",
top: 0,
left: 0,
}}
/>
<Text className="font-bold text-xl text-start px-4">
{library.Name}
</Text>
</View>
</TouchableOpacity>
);
};

View File

@@ -1,4 +1,3 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { Platform } from "react-native"; import { Platform } from "react-native";
@@ -16,9 +15,6 @@ export default function SearchLayout() {
headerShadowVisible: false, headerShadowVisible: false,
}} }}
/> />
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
</Stack> </Stack>
); );
} }

View File

@@ -1,3 +1,4 @@
import { Button } from "@/components/Button";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll"; import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -11,6 +12,8 @@ import SeriesPoster from "@/components/posters/SeriesPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import { import {
BaseItemDto, BaseItemDto,
BaseItemKind, BaseItemKind,
@@ -18,7 +21,13 @@ import {
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router"; import {
Href,
router,
useLocalSearchParams,
useNavigation,
usePathname,
} from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { import React, {
useCallback, useCallback,
@@ -28,7 +37,6 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
const exampleSearches = [ const exampleSearches = [
@@ -42,7 +50,6 @@ const exampleSearches = [
export default function search() { export default function search() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
const { q, prev } = params as { q: string; prev: Href<string> }; const { q, prev } = params as { q: string; prev: Href<string> };
@@ -222,10 +229,6 @@ export default function search() {
<ScrollView <ScrollView
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
> >
<View className="flex flex-col pt-4 pb-32"> <View className="flex flex-col pt-4 pb-32">
{Platform.OS === "android" && ( {Platform.OS === "android" && (
@@ -251,13 +254,13 @@ export default function search() {
header="Movies" header="Movies"
ids={movies?.map((m) => m.Id!)} ids={movies?.map((m) => m.Id!)}
renderItem={(data) => ( renderItem={(data) => (
<HorizontalScroll <HorizontalScroll<BaseItemDto>
data={data} data={data}
renderItem={(item) => ( renderItem={(item) => (
<TouchableItemRouter <TouchableOpacity
key={item.Id} key={item.Id}
className="flex flex-col w-28" className="flex flex-col w-28"
item={item} onPress={() => router.push(`/items/${item.Id}`)}
> >
<MoviePoster item={item} key={item.Id} /> <MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2"> <Text numberOfLines={2} className="mt-2">
@@ -266,7 +269,7 @@ export default function search() {
<Text className="opacity-50 text-xs"> <Text className="opacity-50 text-xs">
{item.ProductionYear} {item.ProductionYear}
</Text> </Text>
</TouchableItemRouter> </TouchableOpacity>
)} )}
/> />
)} )}
@@ -275,7 +278,7 @@ export default function search() {
ids={series?.map((m) => m.Id!)} ids={series?.map((m) => m.Id!)}
header="Series" header="Series"
renderItem={(data) => ( renderItem={(data) => (
<HorizontalScroll <HorizontalScroll<BaseItemDto>
data={data} data={data}
renderItem={(item) => ( renderItem={(item) => (
<TouchableOpacity <TouchableOpacity
@@ -299,12 +302,12 @@ export default function search() {
ids={episodes?.map((m) => m.Id!)} ids={episodes?.map((m) => m.Id!)}
header="Episodes" header="Episodes"
renderItem={(data) => ( renderItem={(data) => (
<HorizontalScroll <HorizontalScroll<BaseItemDto>
data={data} data={data}
renderItem={(item) => ( renderItem={(item) => (
<TouchableOpacity <TouchableOpacity
key={item.Id} key={item.Id}
onPress={() => router.push(`/items/page?id=${item.Id}`)} onPress={() => router.push(`/items/${item.Id}`)}
className="flex flex-col w-44" className="flex flex-col w-44"
> >
<ContinueWatchingPoster item={item} /> <ContinueWatchingPoster item={item} />
@@ -318,7 +321,7 @@ export default function search() {
ids={collections?.map((m) => m.Id!)} ids={collections?.map((m) => m.Id!)}
header="Collections" header="Collections"
renderItem={(data) => ( renderItem={(data) => (
<HorizontalScroll <HorizontalScroll<BaseItemDto>
data={data} data={data}
renderItem={(item) => ( renderItem={(item) => (
<TouchableOpacity <TouchableOpacity
@@ -339,7 +342,7 @@ export default function search() {
ids={actors?.map((m) => m.Id!)} ids={actors?.map((m) => m.Id!)}
header="Actors" header="Actors"
renderItem={(data) => ( renderItem={(data) => (
<HorizontalScroll <HorizontalScroll<BaseItemDto>
data={data} data={data}
renderItem={(item) => ( renderItem={(item) => (
<TouchableItemRouter <TouchableItemRouter
@@ -358,7 +361,7 @@ export default function search() {
ids={artists?.map((m) => m.Id!)} ids={artists?.map((m) => m.Id!)}
header="Artists" header="Artists"
renderItem={(data) => ( renderItem={(data) => (
<HorizontalScroll <HorizontalScroll<BaseItemDto>
data={data} data={data}
renderItem={(item) => ( renderItem={(item) => (
<TouchableItemRouter <TouchableItemRouter
@@ -377,7 +380,7 @@ export default function search() {
ids={albums?.map((m) => m.Id!)} ids={albums?.map((m) => m.Id!)}
header="Albums" header="Albums"
renderItem={(data) => ( renderItem={(data) => (
<HorizontalScroll <HorizontalScroll<BaseItemDto>
data={data} data={data}
renderItem={(item) => ( renderItem={(item) => (
<TouchableItemRouter <TouchableItemRouter
@@ -396,7 +399,7 @@ export default function search() {
ids={songs?.map((m) => m.Id!)} ids={songs?.map((m) => m.Id!)}
header="Songs" header="Songs"
renderItem={(data) => ( renderItem={(data) => (
<HorizontalScroll <HorizontalScroll<BaseItemDto>
data={data} data={data}
renderItem={(item) => ( renderItem={(item) => (
<TouchableItemRouter <TouchableItemRouter

View File

@@ -1,23 +1,34 @@
import { ItemCardText } from "@/components/ItemCardText"; import { Bitrate } from "@/components/BitrateSelector";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { Ratings } from "@/components/Ratings";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader"; import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import MoviePoster from "@/components/posters/MoviePoster"; import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; 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 { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models"; import { chromecastProfile } from "@/utils/profiles/chromecast";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import { getItemsApi, getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo, useState } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
import { ParallaxScrollView } from "../../../components/ParallaxPage";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import MoviePoster from "@/components/posters/MoviePoster";
import { ItemCardText } from "@/components/ItemCardText";
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
const page: React.FC = () => { const page: React.FC = () => {
const local = useLocalSearchParams(); const local = useLocalSearchParams();

View File

@@ -1,9 +1,6 @@
import { Chromecast } from "@/components/Chromecast"; import { Chromecast } from "@/components/Chromecast";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { SongsList } from "@/components/music/SongsList"; import { SongsList } from "@/components/music/SongsList";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import ArtistPoster from "@/components/posters/ArtistPoster"; import ArtistPoster from "@/components/posters/ArtistPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
@@ -13,7 +10,6 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native"; import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() { export default function page() {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
@@ -91,31 +87,35 @@ export default function page() {
enabled: !!api && !!user?.Id, enabled: !!api && !!user?.Id,
}); });
const insets = useSafeAreaInsets();
if (!album) return null; if (!album) return null;
return ( return (
<ParallaxScrollView <ScrollView>
headerHeight={400} <View className="px-4 pb-24">
headerImage={ <View className="flex flex-row space-x-4 items-start mb-4">
<ItemImage <View className="w-24">
variant={"Primary"} <ArtistPoster item={album} />
item={album} </View>
style={{ <View className="flex flex-col shrink">
width: "100%", <Text className="font-bold text-3xl">{album?.Name}</Text>
height: "100%", <Text className="">{album?.ProductionYear}</Text>
}}
/> <View className="flex flex-row space-x-2 mt-1">
} {album.AlbumArtists?.map((a) => (
> <TouchableOpacity
<View className="px-4 mb-8"> key={a.Id}
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text> onPress={() => {
<Text className="text-neutral-500"> router.push(`/artists/${a.Id}/page`);
{songs?.TotalRecordCount} songs }}
</Text> >
</View> <Text className="font-bold text-purple-600">
<View className="px-4"> {album?.AlbumArtist}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</View>
<SongsList <SongsList
albumId={albumId} albumId={albumId}
songs={songs?.Items} songs={songs?.Items}
@@ -123,6 +123,6 @@ export default function page() {
artistId={artistId} artistId={artistId}
/> />
</View> </View>
</ParallaxScrollView> </ScrollView>
); );
} }

View File

@@ -8,10 +8,6 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native"; 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() { export default function page() {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
@@ -86,45 +82,50 @@ export default function page() {
enabled: !!api && !!user?.Id, enabled: !!api && !!user?.Id,
}); });
const insets = useSafeAreaInsets(); useEffect(() => {
navigation.setOptions({
title: albums?.Items?.[0]?.AlbumArtist || "",
});
}, [albums]);
if (!artist || !albums) return null; if (!artist || !albums) return null;
return ( return (
<ParallaxScrollView <FlatList
headerHeight={400} contentContainerStyle={{
headerImage={ padding: 16,
<ItemImage paddingBottom: 140,
variant={"Primary"} }}
item={artist} ListHeaderComponent={
style={{ <View className="mb-2">
width: "100%", <View className="w-32 mb-4">
height: "100%", <ArtistPoster item={artist} />
}} </View>
/> <Text className="font-bold text-2xl mb-4">Albums</Text>
</View>
} }
> nestedScrollEnabled
<View className="px-4 mb-8"> data={albums.Items}
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text> numColumns={3}
<Text className="text-neutral-500"> columnWrapperStyle={{
{albums.TotalRecordCount} albums justifyContent: "space-between",
</Text> }}
</View> renderItem={({ item, index }) => (
<View className="flex flex-row flex-wrap justify-between px-4"> <TouchableOpacity
{albums.Items.map((item, idx) => ( style={{ width: "30%" }}
<TouchableItemRouter key={index}
item={item} onPress={() => {
style={{ width: "30%", marginBottom: 20 }} router.push(`/albums/${item.Id}`);
key={idx} }}
> >
<View className="flex flex-col gap-y-2"> <View className="flex flex-col gap-y-2">
<ArtistPoster item={item} /> <ArtistPoster item={item} />
<Text numberOfLines={2}>{item.Name}</Text> <Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text> <Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View> </View>
</TouchableItemRouter> </TouchableOpacity>
))} )}
</View> keyExtractor={(item) => item.Id || ""}
</ParallaxScrollView> />
); );
} }

View File

@@ -1,5 +1,4 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ArtistPoster from "@/components/posters/ArtistPoster"; import ArtistPoster from "@/components/posters/ArtistPoster";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -91,13 +90,15 @@ export default function page() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
<TouchableItemRouter <TouchableOpacity
style={{ style={{
maxWidth: "30%", maxWidth: "30%",
width: "100%", width: "100%",
}} }}
key={index} key={index}
item={item} onPress={() => {
router.push(`/artists/${item.Id}/page`);
}}
> >
<View className="flex flex-col gap-y-2"> <View className="flex flex-col gap-y-2">
{collection?.CollectionType === "movies" && ( {collection?.CollectionType === "movies" && (
@@ -109,7 +110,7 @@ export default function page() {
<Text>{item.Name}</Text> <Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text> <Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View> </View>
</TouchableItemRouter> </TouchableOpacity>
)} )}
keyExtractor={(item) => item.Id || ""} keyExtractor={(item) => item.Id || ""}
/> />

View File

@@ -3,15 +3,14 @@ import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { Loader } from "@/components/Loader";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import {
genreFilterAtom, genreFilterAtom,
sortByAtom, sortByAtom,
SortByOption,
sortOptions, sortOptions,
sortOrderAtom, sortOrderAtom,
SortOrderOption,
sortOrderOptions, sortOrderOptions,
tagsFilterAtom, tagsFilterAtom,
yearFilterAtom, yearFilterAtom,
@@ -19,7 +18,7 @@ import {
import { import {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
ItemSortBy, BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { import {
getFilterApi, getFilterApi,
@@ -28,11 +27,17 @@ import {
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, {
import { FlatList, View } from "react-native"; useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import { FlatList, NativeScrollEvent, ScrollView, View } from "react-native";
import * as ScreenOrientation from "expo-screen-orientation";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter); const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
@@ -53,6 +58,21 @@ const page: React.FC = () => {
const [sortBy, setSortBy] = useAtom(sortByAtom); const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom); const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
useLayoutEffect(() => {
setSortBy([
{
key: "PremiereDate",
value: "Premiere Date",
},
]);
setSortOrder([
{
key: "Ascending",
value: "Ascending",
},
]);
}, []);
const { data: collection } = useQuery({ const { data: collection } = useQuery({
queryKey: ["collection", collectionId], queryKey: ["collection", collectionId],
queryFn: async () => { queryFn: async () => {
@@ -70,18 +90,6 @@ const page: React.FC = () => {
useEffect(() => { useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" }); navigation.setOptions({ title: collection?.Name || "" });
setSortOrder([SortOrderOption.Ascending]);
if (!collection) return;
// Convert the DisplayOrder to SortByOption
const displayOrder = collection.DisplayOrder as ItemSortBy;
const sortByOption = displayOrder
? SortByOption[displayOrder as keyof typeof SortByOption] ||
SortByOption.PremiereDate
: SortByOption.PremiereDate;
setSortBy([sortByOption]);
}, [navigation, collection]); }, [navigation, collection]);
const fetchItems = useCallback( const fetchItems = useCallback(
@@ -97,9 +105,8 @@ const page: React.FC = () => {
parentId: collectionId, parentId: collectionId,
limit: 18, limit: 18,
startIndex: pageParam, startIndex: pageParam,
// Set one ordering at a time. As collections do not work with correctly with multiple. sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
sortBy: [sortBy[0]], sortOrder: [sortOrder[0].key],
sortOrder: [sortOrder[0]],
fields: [ fields: [
"ItemCounts", "ItemCounts",
"PrimaryImageAspectRatio", "PrimaryImageAspectRatio",
@@ -189,8 +196,7 @@ const page: React.FC = () => {
width: "89%", width: "89%",
}} }}
> >
<ItemPoster item={item} /> <MoviePoster item={item} />
{/* <MoviePoster item={item} /> */}
<ItemCardText item={item} /> <ItemCardText item={item} />
</View> </View>
</MemoizedTouchableItemRouter> </MemoizedTouchableItemRouter>
@@ -212,13 +218,6 @@ const page: React.FC = () => {
paddingVertical: 16, paddingVertical: 16,
flexDirection: "row", flexDirection: "row",
}} }}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
data={[ data={[
{ {
key: "reset", key: "reset",
@@ -310,15 +309,13 @@ const page: React.FC = () => {
className="mr-1" className="mr-1"
collectionId={collectionId} collectionId={collectionId}
queryKey="sortBy" queryKey="sortBy"
queryFn={async () => sortOptions.map((s) => s.key)} queryFn={async () => sortOptions}
set={setSortBy} set={setSortBy}
values={sortBy} values={sortBy}
title="Sort By" title="Sort By"
renderItemLabel={(item) => renderItemLabel={(item) => item.value}
sortOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.value.toLowerCase().includes(search.toLowerCase())
} }
/> />
), ),
@@ -330,15 +327,13 @@ const page: React.FC = () => {
className="mr-1" className="mr-1"
collectionId={collectionId} collectionId={collectionId}
queryKey="sortOrder" queryKey="sortOrder"
queryFn={async () => sortOrderOptions.map((s) => s.key)} queryFn={async () => sortOrderOptions}
set={setSortOrder} set={setSortOrder}
values={sortOrder} values={sortOrder}
title="Sort Order" title="Sort Order"
renderItemLabel={(item) => renderItemLabel={(item) => item.value}
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.value.toLowerCase().includes(search.toLowerCase())
} }
/> />
), ),
@@ -376,13 +371,6 @@ const page: React.FC = () => {
<Text className="font-bold text-xl text-neutral-500">No results</Text> <Text className="font-bold text-xl text-neutral-500">No results</Text>
</View> </View>
} }
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
data={flatData} data={flatData}
renderItem={renderItem} renderItem={renderItem}

View File

@@ -13,7 +13,6 @@ import { FFmpegKit } from "ffmpeg-kit-react-native";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native"; import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const downloads: React.FC = () => { const downloads: React.FC = () => {
const [process, setProcess] = useAtom(runningProcesses); const [process, setProcess] = useAtom(runningProcesses);
@@ -54,8 +53,6 @@ const downloads: React.FC = () => {
return formatNumber(timeLeft / 10000); return formatNumber(timeLeft / 10000);
}, [process]); }, [process]);
const insets = useSafeAreaInsets();
if (isLoading) { if (isLoading) {
return ( return (
<View className="h-full flex flex-col items-center justify-center -mt-6"> <View className="h-full flex flex-col items-center justify-center -mt-6">
@@ -65,13 +62,7 @@ const downloads: React.FC = () => {
} }
return ( return (
<ScrollView <ScrollView>
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="px-4 py-4"> <View className="px-4 py-4">
<View className="mb-4 flex flex-col space-y-4"> <View className="mb-4 flex flex-col space-y-4">
<View> <View>
@@ -79,9 +70,7 @@ const downloads: React.FC = () => {
<View className="flex flex-col space-y-2"> <View className="flex flex-col space-y-2">
{queue.map((q) => ( {queue.map((q) => (
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() => router.push(`/(auth)/items/${q.item.Id}`)}
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" className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
> >
<View> <View>
@@ -108,9 +97,7 @@ const downloads: React.FC = () => {
<Text className="text-2xl font-bold mb-2">Active download</Text> <Text className="text-2xl font-bold mb-2">Active download</Text>
{process?.item ? ( {process?.item ? (
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() => router.push(`/(auth)/items/${process.item.Id}`)}
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" className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
> >
<View> <View>

245
app/(auth)/items/[id].tsx Normal file
View File

@@ -0,0 +1,245 @@
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 { 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";
import { ParallaxScrollView } from "../../../components/ParallaxPage";
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<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
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,
});
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 (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
if (!item?.Id || !backdropUrl) return null;
return (
<ParallaxScrollView
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className="flex flex-col px-4 pt-4">
<View className="flex flex-col">
{item.Type === "Episode" ? (
<SeriesTitleHeader item={item} />
) : (
<>
<MoviesTitleHeader item={item} />
</>
)}
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
<Ratings item={item} />
</View>
<View className="flex flex-row justify-between items-center mb-2">
{playbackUrl ? (
<DownloadItem item={item} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)}
<PlayedStatus item={item} />
</View>
<OverviewText text={item.Overview} />
</View>
<View className="flex flex-col p-4 w-full">
<View className="flex flex-row items-center space-x-2 w-full">
<BitrateSelector
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<AudioTrackSelector
item={item}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
item={item}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
<View className="flex flex-row items-center justify-between w-full">
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton item={item} url={playbackUrl} className="grow" />
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>
<CastAndCrew item={item} />
{item.Type === "Episode" && (
<View className="mb-4">
<CurrentSeries item={item} />
</View>
)}
<SimilarItems itemId={item.Id} />
<View className="h-12"></View>
</ParallaxScrollView>
);
};
export default page;

View File

@@ -20,8 +20,6 @@ const page: React.FC = () => {
seasonIndex: string; seasonIndex: string;
}; };
console.log("seasonIndex", seasonIndex);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -61,7 +59,6 @@ const page: React.FC = () => {
return ( return (
<ParallaxScrollView <ParallaxScrollView
headerHeight={400}
headerImage={ headerImage={
<Image <Image
source={{ source={{
@@ -98,7 +95,7 @@ const page: React.FC = () => {
<View className="mb-4"> <View className="mb-4">
<NextUp seriesId={seriesId} /> <NextUp seriesId={seriesId} />
</View> </View>
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} /> <SeasonPicker item={item} />
</View> </View>
</ParallaxScrollView> </ParallaxScrollView>
); );

View File

@@ -1,15 +1,15 @@
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem"; import { ListItem } from "@/components/ListItem";
import { SettingToggles } from "@/components/settings/SettingToggles";
import { useFiles } from "@/hooks/useFiles";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs, readFromLog } from "@/utils/log"; import { clearLogs, readFromLog } from "@/utils/log";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Haptics from "expo-haptics";
import { useFiles } from "@/hooks/useFiles";
import { SettingToggles } from "@/components/settings/SettingToggles";
import { WebSocketsTest } from "@/components/settings/WebsocketsText";
export default function settings() { export default function settings() {
const { logout } = useJellyfin(); const { logout } = useJellyfin();
@@ -24,24 +24,14 @@ export default function settings() {
refetchInterval: 1000, refetchInterval: 1000,
}); });
const insets = useSafeAreaInsets();
return ( return (
<ScrollView <ScrollView>
contentContainerStyle={{ <View className="p-4 flex flex-col gap-y-4 pb-12">
paddingLeft: insets.left, <Text className="font-bold text-2xl">Information</Text>
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="p-4 flex flex-col gap-y-4">
<View>
<Text className="font-bold text-lg mb-2">Information</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 "> <View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<ListItem title="User" subTitle={user?.Name} /> <ListItem title="User" subTitle={user?.Name} />
<ListItem title="Server" subTitle={api?.basePath} /> <ListItem title="Server" subTitle={api?.basePath} />
</View>
</View> </View>
<SettingToggles /> <SettingToggles />
@@ -73,27 +63,26 @@ export default function settings() {
Delete all logs Delete all logs
</Button> </Button>
</View> </View>
<View>
<Text className="font-bold text-lg mb-2">Logs</Text> <Text className="font-bold text-2xl">Logs</Text>
<View className="flex flex-col space-y-2"> <View className="flex flex-col space-y-2">
{logs?.map((log, index) => ( {logs?.map((log, index) => (
<View key={index} className="bg-neutral-900 rounded-xl p-3"> <View key={index} className="bg-neutral-900 rounded-xl p-3">
<Text <Text
className={` className={`
mb-1 mb-1
${log.level === "INFO" && "text-blue-500"} ${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"} ${log.level === "ERROR" && "text-red-500"}
`} `}
> >
{log.level} {log.level}
</Text> </Text>
<Text className="text-xs">{log.message}</Text> <Text className="text-xs">{log.message}</Text>
</View> </View>
))} ))}
{logs?.length === 0 && ( {logs?.length === 0 && (
<Text className="opacity-50">No logs available</Text> <Text className="opacity-50">No logs available</Text>
)} )}
</View>
</View> </View>
</View> </View>
</ScrollView> </ScrollView>

View File

@@ -0,0 +1,271 @@
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: () => (
<View className="">
<Chromecast />
</View>
),
});
});
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
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 (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
if (!item?.Id || !backdropUrl) return null;
return (
<ParallaxScrollView
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className="flex flex-col px-4 pt-4">
<View className="flex flex-col">
<MoviesTitleHeader item={item} />
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
</View>
<View className="flex flex-row justify-between items-center w-full my-4">
{playbackUrl ? (
<DownloadItem item={item} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)}
</View>
</View>
<View className="flex flex-col p-4 w-full">
<View className="flex flex-row items-center space-x-2 w-full">
<BitrateSelector
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<AudioTrackSelector
item={item}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
item={item}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
<View className="flex flex-row items-center justify-between w-full">
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton item={item} className="grow" />
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>
<ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 ">
<View className="flex flex-col">
<Text className="text-sm opacity-70">Audio</Text>
</View>
<View className="flex flex-col">
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
</Text>
</View>
</View>
</ScrollView>
<SimilarItems itemId={item.Id} />
<View className="h-12"></View>
</ParallaxScrollView>
);
};
export default page;

View File

@@ -9,19 +9,22 @@ import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useFonts } from "expo-font"; import { useFonts } from "expo-font";
import { useKeepAwake } from "expo-keep-awake"; import { useKeepAwake } from "expo-keep-awake";
import { Stack, useRouter } from "expo-router"; import { Stack } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "expo-screen-orientation";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { Provider as JotaiProvider, useAtom } from "jotai"; import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated"; import "react-native-reanimated";
import * as Linking from "expo-linking";
import { orientationAtom } from "@/utils/atoms/orientation";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
export const unstable_settings = {
initialRouteName: "/index",
};
export default function RootLayout() { export default function RootLayout() {
const [loaded] = useFonts({ const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
@@ -46,7 +49,6 @@ export default function RootLayout() {
function Layout() { function Layout() {
const [settings, updateSettings] = useSettings(); const [settings, updateSettings] = useSettings();
const [orientation, setOrientation] = useAtom(orientationAtom);
useKeepAwake(); useKeepAwake();
@@ -73,29 +75,6 @@ function Layout() {
); );
}, [settings]); }, [settings]);
useEffect(() => {
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 ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClientRef.current}> <QueryClientProvider client={queryClientRef.current}>
@@ -114,6 +93,88 @@ function Layout() {
title: "", title: "",
}} }}
/> />
<Stack.Screen
name="(auth)/settings"
options={{
headerShown: true,
title: "Settings",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/downloads"
options={{
headerShown: true,
title: "Downloads",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/items/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/actors/[actorId]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/[artistId]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/albums/[albumId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/songs/[songId]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen <Stack.Screen
name="login" name="login"
options={{ headerShown: false, title: "Login" }} options={{ headerShown: false, title: "Login" }}

View File

@@ -3,9 +3,9 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams } from "expo-router"; import { AxiosError } from "axios";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useState } from "react"; import React, { useMemo, useState } from "react";
import { import {
Alert, Alert,
KeyboardAvoidingView, KeyboardAvoidingView,
@@ -21,44 +21,19 @@ const CredentialsSchema = z.object({
}); });
const Login: React.FC = () => { const Login: React.FC = () => {
const { setServer, login, removeServer, initiateQuickConnect } = const { setServer, login, removeServer } = useJellyfin();
useJellyfin();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const params = useLocalSearchParams();
const { const [serverURL, setServerURL] = useState<string>("");
apiUrl: _apiUrl,
username: _username,
password: _password,
} = params as { apiUrl: string; username: string; password: string };
const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const [credentials, setCredentials] = useState<{ const [credentials, setCredentials] = useState<{
username: string; username: string;
password: string; password: string;
}>({ }>({
username: _username, username: "",
password: _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<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => { const handleLogin = async () => {
@@ -87,21 +62,6 @@ const Login: React.FC = () => {
setServer({ address: url.trim() }); 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) { if (api?.basePath) {
return ( return (
<SafeAreaView style={{ flex: 1 }}> <SafeAreaView style={{ flex: 1 }}>
@@ -177,18 +137,13 @@ const Login: React.FC = () => {
<Text className="text-red-600 mb-2">{error}</Text> <Text className="text-red-600 mb-2">{error}</Text>
</View> </View>
<View className="mt-auto mb-2"> <Button
<Button onPress={handleLogin}
color="black" loading={loading}
onPress={handleQuickConnect} className="mt-auto mb-2"
className="mb-2" >
> Log in
Use Quick Connect </Button>
</Button>
<Button onPress={handleLogin} loading={loading}>
Log in
</Button>
</View>
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

BIN
bun.lockb

Binary file not shown.

View File

@@ -2,32 +2,27 @@ import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models"; import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools"; import { tc } from "@/utils/textTools";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo; item: BaseItemDto;
onChange: (value: number) => void; onChange: (value: number) => void;
selected: number; selected: number;
} }
export const AudioTrackSelector: React.FC<Props> = ({ export const AudioTrackSelector: React.FC<Props> = ({
source, item,
onChange, onChange,
selected, selected,
...props ...props
}) => { }) => {
const [settings] = useSettings();
const audioStreams = useMemo( const audioStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Audio"), () =>
[source] item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
[item]
); );
const selectedAudioSteam = useMemo( const selectedAudioSteam = useMemo(
@@ -36,39 +31,23 @@ export const AudioTrackSelector: React.FC<Props> = ({
); );
useEffect(() => { useEffect(() => {
const defaultAudioIndex = audioStreams?.find( const index = item.MediaSources?.[0].DefaultAudioStreamIndex;
(x) => x.Language === settings?.defaultAudioLanguage if (index !== undefined && index !== null) onChange(index);
)?.Index; }, []);
if (defaultAudioIndex !== undefined && defaultAudioIndex !== null) {
onChange(defaultAudioIndex);
return;
}
const index = source.DefaultAudioStreamIndex;
if (index !== undefined && index !== null) {
console.log("DefaultAudioStreamIndex", index);
onChange(index);
return;
}
onChange(0);
}, [audioStreams, settings]);
return ( return (
<View <View className="flex flex-row items-center justify-between" {...props}>
className="flex shrink"
style={{
minWidth: 50,
}}
>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Audio</Text> <Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <View className="flex flex-row">
<Text className="" numberOfLines={1}> <TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
{selectedAudioSteam?.DisplayTitle} <Text className="">
</Text> {tc(selectedAudioSteam?.DisplayTitle, 13)}
</TouchableOpacity> </Text>
</TouchableOpacity>
</View>
</View> </View>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content

View File

@@ -1,12 +1,11 @@
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useMemo } from "react"; import { atom, useAtom } from "jotai";
export type Bitrate = { export type Bitrate = {
key: string; key: string;
value: number | undefined; value: number | undefined;
height?: number;
}; };
const BITRATES: Bitrate[] = [ const BITRATES: Bitrate[] = [
@@ -17,84 +16,63 @@ const BITRATES: Bitrate[] = [
{ {
key: "8 Mb/s", key: "8 Mb/s",
value: 8000000, value: 8000000,
height: 1080,
}, },
{ {
key: "4 Mb/s", key: "4 Mb/s",
value: 4000000, value: 4000000,
height: 1080,
}, },
{ {
key: "2 Mb/s", key: "2 Mb/s",
value: 2000000, value: 2000000,
height: 720,
}, },
{ {
key: "500 Kb/s", key: "500 Kb/s",
value: 500000, value: 500000,
height: 480,
}, },
{ {
key: "250 Kb/s", key: "250 Kb/s",
value: 250000, value: 250000,
height: 480,
}, },
]; ];
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void; onChange: (value: Bitrate) => void;
selected: Bitrate; selected: Bitrate;
inverted?: boolean;
} }
export const BitrateSelector: React.FC<Props> = ({ export const BitrateSelector: React.FC<Props> = ({
onChange, onChange,
selected, selected,
inverted,
...props ...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 ( return (
<View <View className="flex flex-row items-center justify-between" {...props}>
className="flex shrink"
style={{
minWidth: 60,
maxWidth: 200,
}}
>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Quality</Text> <Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <View className="flex flex-row">
<Text style={{}} className="" numberOfLines={1}> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
{BITRATES.find((b) => b.value === selected.value)?.key} <Text>
</Text> {BITRATES.find((b) => b.value === selected.value)?.key}
</TouchableOpacity> </Text>
</TouchableOpacity>
</View>
</View> </View>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
loop={false} loop={true}
side="bottom" side="bottom"
align="center" align="start"
alignOffset={0} alignOffset={0}
avoidCollisions={true} avoidCollisions={true}
collisionPadding={0} collisionPadding={8}
sideOffset={0} sideOffset={8}
> >
<DropdownMenu.Label>Bitrates</DropdownMenu.Label> <DropdownMenu.Label>Bitrates</DropdownMenu.Label>
{sorted.map((b) => ( {BITRATES?.map((b, index: number) => (
<DropdownMenu.Item <DropdownMenu.Item
key={b.key} key={index.toString()}
onSelect={() => { onSelect={() => {
onChange(b); onChange(b);
}} }}

View File

@@ -10,7 +10,7 @@ interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
disabled?: boolean; disabled?: boolean;
children?: string | ReactNode; children?: string | ReactNode;
loading?: boolean; loading?: boolean;
color?: "purple" | "red" | "black" | "transparent"; color?: "purple" | "red" | "black";
iconRight?: ReactNode; iconRight?: ReactNode;
iconLeft?: ReactNode; iconLeft?: ReactNode;
justify?: "center" | "between"; justify?: "center" | "between";
@@ -37,8 +37,6 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
return "bg-red-600"; return "bg-red-600";
case "black": case "black":
return "bg-neutral-900 border border-neutral-800"; return "bg-neutral-900 border border-neutral-800";
case "transparent":
return "bg-transparent";
} }
}, [color]); }, [color]);

View File

@@ -1,33 +1,25 @@
import { Feather } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { BlurView } from "expo-blur";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { Platform, TouchableOpacity, ViewProps } from "react-native"; import { View } from "react-native";
import GoogleCast, { import {
CastContext, CastButton,
useCastDevice, useCastDevice,
useDevices, useDevices,
useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
} from "react-native-google-cast"; } from "react-native-google-cast";
import GoogleCast from "react-native-google-cast";
interface Props extends ViewProps { type Props = {
width?: number; width?: number;
height?: number; height?: number;
background?: "blur" | "transparent"; };
}
export const Chromecast: React.FC<Props> = ({ export const Chromecast: React.FC<Props> = ({ width = 48, height = 48 }) => {
width = 48,
height = 48,
background = "transparent",
...props
}) => {
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const castDevice = useCastDevice(); const castDevice = useCastDevice();
const devices = useDevices(); const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager(); const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager(); const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus();
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -39,49 +31,9 @@ export const Chromecast: React.FC<Props> = ({
})(); })();
}, [client, devices, castDevice, sessionManager, discoveryManager]); }, [client, devices, castDevice, sessionManager, discoveryManager]);
if (background === "transparent")
return (
<TouchableOpacity
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
className="rounded-full h-10 w-10 flex items-center justify-center b"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
);
if (Platform.OS === "android")
return (
<TouchableOpacity
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
);
return ( return (
<TouchableOpacity <View className="rounded h-10 aspect-square flex items-center justify-center">
onPress={() => { <CastButton style={{ tintColor: "white", height, width }} />
if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); </View>
else CastContext.showCastDialog();
}}
{...props}
>
<BlurView
intensity={100}
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</BlurView>
</TouchableOpacity>
); );
}; };

View File

@@ -5,41 +5,29 @@ import { useAtom } from "jotai";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator"; import { WatchedIndicator } from "./WatchedIndicator";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
type ContinueWatchingPosterProps = { type ContinueWatchingPosterProps = {
item: BaseItemDto; item: BaseItemDto;
width?: number; width?: number;
useEpisodePoster?: boolean;
}; };
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item, item,
width = 176, width = 176,
useEpisodePoster = false,
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
/** const url = useMemo(
* Get horrizontal poster for movie and episode, with failover to primary. () =>
*/ getPrimaryImageUrl({
const url = useMemo(() => { api,
if (!api) return; item,
if (item.Type === "Episode" && useEpisodePoster) { quality: 80,
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; width: 300,
} }),
if (item.Type === "Episode") { [item]
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( const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0 item.UserData?.PlayedPercentage || 0

View File

@@ -7,7 +7,7 @@ import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import { useRouter, useSegments } from "expo-router"; import { useRouter, useSegments } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native"; import { Alert, Platform, TouchableOpacity, View } from "react-native";
import Animated, { import Animated, {
useAnimatedStyle, useAnimatedStyle,
@@ -17,6 +17,14 @@ import Animated, {
import Video from "react-native-video"; import Video from "react-native-video";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import * as FileSystem from "expo-file-system";
import {
FFmpegKit,
FFmpegKitConfig,
FFmpegSession,
ReturnCode,
} from "ffmpeg-kit-react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
export const CurrentlyPlayingBar: React.FC = () => { export const CurrentlyPlayingBar: React.FC = () => {
const segments = useSegments(); const segments = useSegments();
@@ -24,17 +32,16 @@ export const CurrentlyPlayingBar: React.FC = () => {
currentlyPlaying, currentlyPlaying,
pauseVideo, pauseVideo,
playVideo, playVideo,
setCurrentlyPlayingState,
stopPlayback, stopPlayback,
setVolume,
setIsPlaying, setIsPlaying,
isPlaying, isPlaying,
videoRef, videoRef,
presentFullscreenPlayer,
onProgress, onProgress,
onBuffer,
} = usePlayback(); } = usePlayback();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const aBottom = useSharedValue(0); const aBottom = useSharedValue(0);
const aPadding = useSharedValue(0); const aPadding = useSharedValue(0);
@@ -64,7 +71,105 @@ export const CurrentlyPlayingBar: React.FC = () => {
}; };
}); });
const from = useMemo(() => segments[2], [segments]); const [streamUrl, setStreamUrl] = useState<string | null>(null);
const [ffmpegSession, setFfmpegSession] = useState<FFmpegSession | null>(
null
);
const startStreamingTranscode = async (inputUrl: string) => {
const outputDir = `${FileSystem.cacheDirectory}stream_${Date.now()}`;
const manifestPath = `${outputDir}/stream.m3u8`;
// Ensure the output directory exists
await FileSystem.makeDirectoryAsync(outputDir, { intermediates: true });
// Base FFmpeg command
let ffmpegCommand = `-i "${inputUrl}" `;
// Add hardware acceleration based on platform
if (Platform.OS === "android") {
ffmpegCommand += "-c:v h264_mediacodec "; // Hardware acceleration for Android
} else if (Platform.OS === "ios") {
ffmpegCommand += "-c:v h264_videotoolbox "; // Hardware acceleration for iOS
} else {
ffmpegCommand += "-c:v libx264 "; // Fallback to software encoding
}
// Complete the command
ffmpegCommand += `-c:a aac -f hls -hls_time 4 -hls_list_size 5 -hls_flags delete_segments "${manifestPath}"`;
console.log("FFmpeg command:", ffmpegCommand);
// Start FFmpeg process and return the session
return FFmpegKit.executeAsync(ffmpegCommand);
};
useEffect(() => {
const prepareStream = async () => {
if (currentlyPlaying?.url) {
try {
// Check if we already have a stream for this URL
const existingStream = await AsyncStorage.getItem(
currentlyPlaying.url
);
if (existingStream) {
setStreamUrl(existingStream);
} else {
const session = await startStreamingTranscode(currentlyPlaying.url);
setFfmpegSession(session);
const returnCode = await session.getReturnCode();
if (ReturnCode.isSuccess(returnCode)) {
console.log("Transcoding completed successfully");
const outputDir = `${
FileSystem.cacheDirectory
}stream_${Date.now()}`;
const manifestPath = `${outputDir}/stream.m3u8`;
setStreamUrl(manifestPath);
// Store the stream URL
await AsyncStorage.setItem(currentlyPlaying.url, manifestPath);
} else {
console.error("Transcoding failed");
// Handle failure (e.g., retry or show error message)
}
}
} catch (error) {
console.error("Error preparing stream:", error);
}
}
};
prepareStream();
return () => {
// Cleanup: cancel FFmpeg session when component unmounts
if (ffmpegSession) {
ffmpegSession.cancel();
}
};
}, [currentlyPlaying?.url]);
// Cleanup function
useEffect(() => {
return () => {
const cleanup = async () => {
if (streamUrl) {
try {
// Remove the stream URL from AsyncStorage
await AsyncStorage.removeItem(currentlyPlaying?.url || "");
// Delete the stream files
await FileSystem.deleteAsync(streamUrl.replace("file://", ""), {
idempotent: true,
});
} catch (error) {
console.error("Error cleaning up stream:", error);
}
}
};
cleanup();
};
}, [streamUrl, currentlyPlaying?.url]);
useEffect(() => { useEffect(() => {
if (segments.find((s) => s.includes("tabs"))) { if (segments.find((s) => s.includes("tabs"))) {
@@ -94,40 +199,16 @@ export const CurrentlyPlayingBar: React.FC = () => {
[currentlyPlaying?.item] [currentlyPlaying?.item]
); );
const poster = useMemo(() => { const backdropUrl = 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`; getBackdropUrl({
else
return getBackdropUrl({
api, api,
item: currentlyPlaying?.item, item: currentlyPlaying?.item,
quality: 70, quality: 70,
width: 200, width: 200,
}); }),
}, [currentlyPlaying?.item.Id, api]); [currentlyPlaying?.item, 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; if (!api || !currentlyPlaying) return null;
@@ -163,7 +244,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
} }
`} `}
> >
{videoSource && ( {streamUrl && (
<Video <Video
ref={videoRef} ref={videoRef}
allowsExternalPlayback allowsExternalPlayback
@@ -175,43 +256,40 @@ export const CurrentlyPlayingBar: React.FC = () => {
controls={false} controls={false}
pictureInPicture={true} pictureInPicture={true}
poster={ poster={
poster && currentlyPlaying.item?.Type === "Audio" backdropUrl && currentlyPlaying.item?.Type === "Audio"
? poster ? backdropUrl
: undefined : undefined
} }
debug={{ debug={{
enable: true, enable: true,
thread: true, thread: true,
}} }}
onIdle={() => { paused={!isPlaying}
console.log("IDLE");
}}
fullscreenAutorotate={true}
onReadyForDisplay={() => {
console.log("READY FOR DISPLAY");
}}
onProgress={(e) => onProgress(e)} onProgress={(e) => onProgress(e)}
subtitleStyle={{ subtitleStyle={{
fontSize: 16, fontSize: 16,
}} }}
onBuffer={(e) => onBuffer(e.isBuffering)} source={{
source={videoSource} uri: streamUrl,
onRestoreUserInterfaceForPictureInPictureStop={() => { isNetwork: true,
setTimeout(() => { startPosition,
presentFullscreenPlayer(); headers: getAuthHeaders(api),
}, 300);
}} }}
onBuffer={(e) =>
e.isBuffering ? console.log("Buffering...") : null
}
onFullscreenPlayerDidDismiss={() => {}}
onFullscreenPlayerDidPresent={() => {}}
onPlaybackStateChanged={(e) => { onPlaybackStateChanged={(e) => {
if (e.isPlaying === true) { if (e.isPlaying) {
playVideo(false); setIsPlaying(true);
} else if (e.isPlaying === false) { } else if (e.isSeeking) {
pauseVideo(false); return;
} else {
setIsPlaying(false);
} }
}} }}
onVolumeChange={(e) => { progressUpdateInterval={2000}
setVolume(e.volume);
}}
progressUpdateInterval={4000}
onError={(e) => { onError={(e) => {
console.log(e); console.log(e);
writeToLog( writeToLog(
@@ -235,17 +313,9 @@ export const CurrentlyPlayingBar: React.FC = () => {
<View className="shrink text-xs"> <View className="shrink text-xs">
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
if (currentlyPlaying.item?.Type === "Audio") { if (currentlyPlaying.item?.Type === "Audio")
router.push( router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
// @ts-ignore else router.push(`/items/${currentlyPlaying.item?.Id}`);
`/(auth)/(tabs)/${from}/albums/${currentlyPlaying.item.AlbumId}`
);
} else {
router.push(
// @ts-ignore
`/(auth)/(tabs)/${from}/items/page?id=${currentlyPlaying.item?.Id}`
);
}
}} }}
> >
<Text>{currentlyPlaying.item?.Name}</Text> <Text>{currentlyPlaying.item?.Name}</Text>
@@ -254,8 +324,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
router.push( router.push(
// @ts-ignore `/(auth)/series/${currentlyPlaying.item.SeriesId}`
`/(auth)/(tabs)/${from}/series/${currentlyPlaying.item.SeriesId}`
); );
}} }}
className="text-xs opacity-50" className="text-xs opacity-50"

View File

@@ -2,17 +2,8 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runningProcesses } from "@/utils/atoms/downloads"; import { runningProcesses } from "@/utils/atoms/downloads";
import { queueActions, queueAtom } from "@/utils/atoms/queue"; import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings"; import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
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 Ionicons from "@expo/vector-icons/Ionicons";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
@@ -21,18 +12,21 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router"; import { router } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react"; import {
import { Alert, TouchableOpacity, View, ViewProps } from "react-native"; TouchableOpacity,
import { AudioTrackSelector } from "./AudioTrackSelector"; TouchableOpacityProps,
import { Bitrate, BitrateSelector } from "./BitrateSelector"; View,
import { Button } from "./Button"; ViewProps,
import { Text } from "./common/Text"; } from "react-native";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle"; import ProgressCircle from "./ProgressCircle";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; 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";
interface DownloadProps extends ViewProps { interface DownloadProps extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
} }
@@ -41,135 +35,100 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses); const [process] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const { startRemuxing } = useRemuxHlsToMp4(item); const { startRemuxing } = useRemuxHlsToMp4(item);
const [selectedMediaSource, setSelectedMediaSource] = const initiateDownload = useCallback(
useState<MediaSourceInfo | null>(null); async (qualitySetting: DownloadQuality) => {
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1); if (!api || !user?.Id || !item.Id) {
const [selectedSubtitleStream, setSelectedSubtitleStream] = throw new Error(
useState<number>(0); "DownloadItem ~ initiateDownload: No api or user or item"
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({ );
key: "Max",
value: undefined,
});
const userCanDownload = useMemo(() => {
return user?.Policy?.EnableContentDownloading;
}, [user]);
/**
* Bottom sheet
*/
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const handlePresentModalPress = useCallback(() => {
bottomSheetModalRef.current?.present();
}, []);
const handleSheetChanges = useCallback((index: number) => {
console.log("handleSheetChanges", index);
}, []);
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 deviceProfile: any = ios;
if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
}
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}"`,
},
} }
);
let url: string | undefined = undefined; let deviceProfile: any = ios;
const mediaSource: MediaSourceInfo = response.data.MediaSources.find( if (settings?.deviceProfile === "Native") {
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id deviceProfile = native;
); } else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
}
if (!mediaSource) { let maxStreamingBitrate: number | undefined = undefined;
throw new Error("No media source");
}
if (mediaSource.SupportsDirectPlay) { if (qualitySetting === "high") {
if (item.MediaType === "Video") { maxStreamingBitrate = 8000000;
console.log("Using direct stream for video!"); } else if (qualitySetting === "low") {
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`; maxStreamingBitrate = 2000000;
} else if (item.MediaType === "Audio") { }
console.log("Using direct stream for audio!");
const searchParams = new URLSearchParams({ const response = await api.axiosInstance.post(
`${api.basePath}/Items/${item.Id}/PlaybackInfo`,
{
DeviceProfile: deviceProfile,
UserId: user.Id, UserId: user.Id,
DeviceId: api.deviceInfo.id, MaxStreamingBitrate: maxStreamingBitrate,
MaxStreamingBitrate: "140000000", StartTimeTicks: 0,
Container: EnableTranscoding: maxStreamingBitrate ? true : undefined,
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg", AutoOpenLiveStream: true,
TranscodingContainer: "mp4", MediaSourceId: item.Id,
TranscodingProtocol: "hls", AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
AudioCodec: "aac", },
api_key: api.accessToken, {
StartTimeTicks: "0", headers: {
EnableRedirection: "true", Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
EnableRemoteMedia: "false", },
}); }
url = `${api.basePath}/Audio/${ );
item.Id
}/universal?${searchParams.toString()}`; let url: string | undefined = undefined;
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
if (!mediaSource) {
throw new Error("No media source");
} }
} else if (mediaSource.TranscodingUrl) {
console.log("Using transcoded stream!");
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
}
if (!url) throw new Error("No url"); 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()}`;
}
}
return await startRemuxing(url); if (mediaSource.TranscodingUrl) {
}, [ console.log("Using transcoded stream!");
api, url = `${api.basePath}${mediaSource.TranscodingUrl}`;
item, } else {
startRemuxing, throw new Error("No transcoding url");
user?.Id, }
selectedMediaSource,
selectedAudioStream, return await startRemuxing(url);
selectedSubtitleStream, },
maxBitrate, [api, item, startRemuxing, user?.Id]
]); );
/**
* Check if item is downloaded
*/
const { data: downloaded, isFetching } = useQuery({ const { data: downloaded, isFetching } = useQuery({
queryKey: ["downloaded", item.Id], queryKey: ["downloaded", item.Id],
queryFn: async () => { queryFn: async () => {
@@ -184,30 +143,23 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
enabled: !!item.Id, enabled: !!item.Id,
}); });
const renderBackdrop = useCallback( if (isFetching) {
(props: BottomSheetBackdropProps) => ( return (
<BottomSheetBackdrop <View className="rounded h-10 aspect-square flex items-center justify-center">
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
return (
<View
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
{...props}
>
{isFetching ? (
<Loader /> <Loader />
) : process && process?.item.Id === item.Id ? ( </View>
<TouchableOpacity );
onPress={() => { }
router.push("/downloads");
}} if (process && process?.item.Id === item.Id) {
> return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
{...props}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
{process.progress === 0 ? ( {process.progress === 0 ? (
<Loader /> <Loader />
) : ( ) : (
@@ -221,97 +173,61 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
/> />
</View> </View>
)} )}
</TouchableOpacity> </View>
) : queue.some((i) => i.id === item.Id) ? ( </TouchableOpacity>
<TouchableOpacity );
onPress={() => { }
router.push("/downloads");
}} if (queue.some((i) => i.id === item.Id)) {
> return (
<Ionicons name="hourglass" size={24} color="white" /> <TouchableOpacity
</TouchableOpacity> onPress={() => {
) : downloaded ? ( router.push("/downloads");
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<Ionicons name="cloud-download" size={26} color="#9333ea" />
</TouchableOpacity>
) : (
<TouchableOpacity onPress={handlePresentModalPress}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
</TouchableOpacity>
)}
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}} }}
backgroundStyle={{ {...props}
backgroundColor: "#171717",
}}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
> >
<BottomSheetView> <View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2"> <Ionicons name="hourglass" size={24} color="white" />
<Text className="font-bold text-2xl text-neutral-10"> </View>
Download options </TouchableOpacity>
</Text> );
<View className="flex flex-col space-y-2 w-full items-start"> }
<BitrateSelector
inverted if (downloaded) {
onChange={(val) => setMaxBitrate(val)} return (
selected={maxBitrate} <TouchableOpacity
/> onPress={() => {
<MediaSourceSelector router.push("/downloads");
item={item} }}
onChange={setSelectedMediaSource} {...props}
selected={selectedMediaSource} >
/> <View className="rounded h-10 aspect-square flex items-center justify-center">
{selectedMediaSource && ( <Ionicons name="cloud-download" size={26} color="#9333ea" />
<View className="flex flex-col space-y-2"> </View>
<AudioTrackSelector </TouchableOpacity>
source={selectedMediaSource} );
onChange={setSelectedAudioStream} } else {
selected={selectedAudioStream} return (
/> <TouchableOpacity
<SubtitleTrackSelector onPress={() => {
source={selectedMediaSource} queueActions.enqueue(queue, setQueue, {
onChange={setSelectedSubtitleStream} id: item.Id!,
selected={selectedSubtitleStream} execute: async () => {
/> // await startRemuxing(playbackUrl);
</View> if (!settings?.downloadQuality?.value) {
)} throw new Error("No download quality selected");
</View> }
<Button await initiateDownload(settings?.downloadQuality?.value);
className="mt-auto" },
onPress={() => { item,
if (userCanDownload === true) { });
closeModal(); }}
queueActions.enqueue(queue, setQueue, { {...props}
id: item.Id!, >
execute: async () => { <View className="rounded h-10 aspect-square flex items-center justify-center">
await initiateDownload(); <Ionicons name="cloud-download-outline" size={26} color="white" />
}, </View>
item, </TouchableOpacity>
}); );
} else { }
Alert.alert(
"Disabled",
"This user is not allowed to download files."
);
}
}}
color="purple"
>
Download
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</View>
);
}; };

View File

@@ -1,373 +0,0 @@
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 { useImageColors } from "@/hooks/useImageColors";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getItemImage } from "@/utils/getItemImage";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios from "@/utils/profiles/ios";
import 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 * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
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<MediaSourceInfo | null>(null);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
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 && (
<View className="flex flex-row items-center space-x-2">
<Chromecast background="blur" width={22} height={22} />
<DownloadItem item={item} />
<PlayedStatus item={item} />
</View>
),
});
}, [item]);
useEffect(() => {
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
headerHeightRef.current = 230;
return;
}
if (item?.Type === "Episode") headerHeightRef.current = 400;
else if (item?.Type === "Movie") headerHeightRef.current = 500;
else headerHeightRef.current = 400;
}, [item]);
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 themeImageColorSource = useMemo(() => {
if (!api || !item) return;
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
}, [api, item]);
useImageColors(themeImageColorSource?.uri);
const loading = useMemo(() => {
return Boolean(isLoading || isFetching || (logoUrl && loadingLogo));
}, [isLoading, isFetching, loadingLogo, logoUrl]);
const insets = useSafeAreaInsets();
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
{loading && (
<View className="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex flex-col justify-center items-center z-50">
<Loader />
</View>
)}
<ParallaxScrollView
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
headerHeight={headerHeightRef.current}
headerImage={
<>
<Animated.View style={[animatedStyle, { flex: 1 }]}>
{localItem && (
<ItemImage
useThemeColor
variant={
localItem.Type === "Movie" && logoUrl
? "Backdrop"
: "Primary"
}
item={localItem}
style={{
width: "100%",
height: "100%",
}}
/>
)}
</Animated.View>
</>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
/>
) : null}
</>
}
>
<View className="flex flex-col bg-transparent shrink">
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<Animated.View style={[animatedStyle, { flex: 1 }]}>
<ItemHeader item={localItem} className="mb-4" />
{localItem ? (
<View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector
className="mr-1"
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<MediaSourceSelector
className="mr-1"
item={localItem}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
{selectedMediaSource && (
<>
<AudioTrackSelector
className="mr-1"
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</>
)}
</View>
) : (
<View className="h-16">
<View className="bg-neutral-900 h-4 w-2/4 rounded-md mb-1"></View>
<View className="bg-neutral-900 h-10 w-3/4 rounded-lg"></View>
</View>
)}
</Animated.View>
<PlayButton item={item} url={playbackUrl} className="grow" />
</View>
{item?.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} />
)}
<OverviewText text={item?.Overview} className="px-4 mb-4" />
<CastAndCrew item={item} className="mb-4" loading={loading} />
{item?.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<SimilarItems itemId={item?.Id} />
<View className="h-16"></View>
</View>
</ParallaxScrollView>
</View>
);
});

View File

@@ -1,38 +0,0 @@
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<Props> = ({ item, ...props }) => {
if (!item)
return (
<View
className="flex flex-col space-y-1.5 w-full items-start h-24"
{...props}
>
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
<View className="w-2/3 h-8 bg-neutral-900 rounded" />
<View className="w-2/3 h-4 bg-neutral-900 rounded" />
<View className="w-1/4 h-4 bg-neutral-900 rounded" />
</View>
);
return (
<View
style={{
minHeight: 96,
}}
className="flex flex-col"
{...props}
>
<Ratings item={item} className="mb-2" />
{item.Type === "Episode" && <EpisodeTitleHeader item={item} />}
{item.Type === "Movie" && <MoviesTitleHeader item={item} />}
</View>
);
};

View File

@@ -1,19 +0,0 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { PropsWithChildren } from "react";
interface Props extends ViewProps {}
export const List: React.FC<PropsWithChildren<Props>> = ({
children,
...props
}) => {
return (
<View
className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800"
{...props}
>
{children}
</View>
);
};

View File

@@ -1,13 +1,8 @@
import { PropsWithChildren, ReactNode } from "react"; import { PropsWithChildren, ReactNode } from "react";
import { import { View, ViewProps } from "react-native";
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
interface Props extends TouchableOpacityProps { interface Props extends ViewProps {
title?: string | null | undefined; title?: string | null | undefined;
subTitle?: string | null | undefined; subTitle?: string | null | undefined;
children?: ReactNode; children?: ReactNode;
@@ -22,7 +17,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
...props ...props
}) => { }) => {
return ( return (
<TouchableOpacity <View
className="flex flex-row items-center justify-between bg-neutral-900 p-4" className="flex flex-row items-center justify-between bg-neutral-900 p-4"
{...props} {...props}
> >
@@ -31,6 +26,6 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
{subTitle && <Text className="text-xs">{subTitle}</Text>} {subTitle && <Text className="text-xs">{subTitle}</Text>}
</View> </View>
{iconAfter} {iconAfter}
</TouchableOpacity> </View>
); );
}; };

View File

@@ -1,89 +0,0 @@
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<typeof View> {
item: BaseItemDto;
onChange: (value: MediaSourceInfo) => void;
selected: MediaSourceInfo | null;
}
export const MediaSourceSelector: React.FC<Props> = ({
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 (
<View
className="flex shrink"
style={{
minWidth: 50,
}}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Video</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center ">
<Text numberOfLines={1}>{selectedMediaSource}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
{mediaSources?.map((source, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
onChange(source);
}}
>
<DropdownMenu.ItemTitle>
{name(source.Name)}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -10,32 +10,35 @@ interface Props extends ViewProps {
export const OverviewText: React.FC<Props> = ({ export const OverviewText: React.FC<Props> = ({
text, text,
characterLimit = 100, characterLimit = 140,
...props ...props
}) => { }) => {
const [limit, setLimit] = useState(characterLimit); const [limit, setLimit] = useState(characterLimit);
if (!text) return null; if (!text) return null;
return ( if (text.length > characterLimit)
<View className="flex flex-col" {...props}> return (
<Text className="text-xl font-bold mb-2">Overview</Text>
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() =>
setLimit((prev) => setLimit((prev) =>
prev === characterLimit ? text.length : characterLimit prev === characterLimit ? text.length : characterLimit
) )
} }
{...props}
> >
<View> <View {...props} className="">
<Text>{tc(text, limit)}</Text> <Text>{tc(text, limit)}</Text>
{text.length > characterLimit && ( <Text className="text-purple-600 mt-1">
<Text className="text-purple-600 mt-1"> {limit === characterLimit ? "Show more" : "Show less"}
{limit === characterLimit ? "Show more" : "Show less"} </Text>
</Text>
)}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
);
return (
<View {...props}>
<Text>{text}</Text>
</View> </View>
); );
}; };

View File

@@ -1,27 +1,27 @@
import { LinearGradient } from "expo-linear-gradient"; import { Ionicons } from "@expo/vector-icons";
import { type PropsWithChildren, type ReactElement } from "react"; import { router } from "expo-router";
import { View, ViewProps } from "react-native"; import type { PropsWithChildren, ReactElement } from "react";
import { TouchableOpacity, View } from "react-native";
import Animated, { import Animated, {
interpolate, interpolate,
useAnimatedRef, useAnimatedRef,
useAnimatedStyle, useAnimatedStyle,
useScrollViewOffset, useScrollViewOffset,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
interface Props extends ViewProps { const HEADER_HEIGHT = 400;
type Props = PropsWithChildren<{
headerImage: ReactElement; headerImage: ReactElement;
logo?: ReactElement; logo?: ReactElement;
episodePoster?: ReactElement; }>;
headerHeight?: number;
}
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({ export const ParallaxScrollView: React.FC<Props> = ({
children, children,
headerImage, headerImage,
episodePoster,
headerHeight = 400,
logo, logo,
...props
}: Props) => { }: Props) => {
const scrollRef = useAnimatedRef<Animated.ScrollView>(); const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(scrollRef); const scrollOffset = useScrollViewOffset(scrollRef);
@@ -32,23 +32,25 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
{ {
translateY: interpolate( translateY: interpolate(
scrollOffset.value, scrollOffset.value,
[-headerHeight, 0, headerHeight], [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-headerHeight / 2, 0, headerHeight * 0.75] [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
), ),
}, },
{ {
scale: interpolate( scale: interpolate(
scrollOffset.value, scrollOffset.value,
[-headerHeight, 0, headerHeight], [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[2, 1, 1] [2, 1, 1],
), ),
}, },
], ],
}; };
}); });
const inset = useSafeAreaInsets();
return ( return (
<View className="flex-1" {...props}> <View className="flex-1">
<Animated.ScrollView <Animated.ScrollView
style={{ style={{
position: "relative", position: "relative",
@@ -56,14 +58,32 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
ref={scrollRef} ref={scrollRef}
scrollEventThrottle={16} scrollEventThrottle={16}
> >
<TouchableOpacity
onPress={() => router.back()}
className="absolute left-4 z-50 bg-black rounded-full p-2 border border-neutral-900"
style={{
top: inset.top + 17,
}}
>
<Ionicons
className="drop-shadow-2xl"
name="arrow-back"
size={24}
color="#077DF2"
/>
</TouchableOpacity>
<View
className="absolute right-4 z-50 bg-black rounded-full p-0.5"
style={{
top: inset.top + 17,
}}
>
<Chromecast width={22} height={22} />
</View>
{logo && ( {logo && (
<View <View className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center">
style={{
top: headerHeight - 200,
height: 130,
}}
className="absolute left-0 w-full z-40 px-4 flex justify-center items-center"
>
{logo} {logo}
</View> </View>
)} )}
@@ -71,7 +91,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
<Animated.View <Animated.View
style={[ style={[
{ {
height: headerHeight, height: HEADER_HEIGHT,
backgroundColor: "black", backgroundColor: "black",
}, },
headerAnimatedStyle, headerAnimatedStyle,
@@ -79,35 +99,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
> >
{headerImage} {headerImage}
</Animated.View> </Animated.View>
<View className="flex-1 overflow-hidden bg-black pb-24">
<View
style={{
top: -50,
}}
className="relative flex-1 bg-transparent pb-24"
>
<LinearGradient
// Background Linear Gradient
colors={["transparent", "rgba(0,0,0,1)"]}
style={{
position: "absolute",
left: 0,
right: 0,
top: -150,
height: 200,
}}
/>
<View
// Background Linear Gradient
style={{
position: "absolute",
left: 0,
right: 0,
top: 50,
height: "100%",
backgroundColor: "black",
}}
/>
{children} {children}
</View> </View>
</Animated.ScrollView> </Animated.ScrollView>

View File

@@ -1,160 +1,60 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider"; import { usePlayback } from "@/providers/PlaybackProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai"; import { View } from "react-native";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
PlayServicesState, PlayServicesState,
useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
} from "react-native-google-cast"; } from "react-native-google-cast";
import Animated, {
Easing,
interpolate,
interpolateColor,
useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Button } from "./Button"; import { Button } from "./Button";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
item?: BaseItemDto | null; item?: BaseItemDto | null;
url?: string | null; url?: string | null;
} }
const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => { export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const { setCurrentlyPlayingState } = usePlayback(); const { setCurrentlyPlayingState } = usePlayback();
const mediaStatus = useMediaStatus();
const [colorAtom] = useAtom(itemThemeColorAtom);
const [api] = useAtom(apiAtom);
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 directStream = useMemo(() => {
return !url?.includes("m3u8");
}, []);
const onPress = async () => { const onPress = async () => {
if (!url || !item) return; if (!url || !item) return;
if (!client) { if (!client) {
setCurrentlyPlayingState({ item, url }); setCurrentlyPlayingState({ item, url });
return; return;
} }
const options = ["Chromecast", "Device", "Cancel"]; const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2; const cancelButtonIndex = 2;
showActionSheetWithOptions( showActionSheetWithOptions(
{ {
options, options,
cancelButtonIndex, cancelButtonIndex,
}, },
async (selectedIndex: number | undefined) => { async (selectedIndex: number | undefined) => {
if (!api) return;
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
const isOpeningCurrentlyPlayingMedia =
currentTitle && currentTitle === item?.Name;
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
await CastContext.getPlayServicesState().then((state) => { await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS) if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state); CastContext.showPlayServicesErrorDialog(state);
else { else {
// If we're opening a currently playing item, don't restart the media. client.loadMedia({
// Instead just open controls. mediaInfo: {
if (isOpeningCurrentlyPlayingMedia) { contentUrl: url,
CastContext.showExpandedControls(); contentType: "video/mp4",
return; metadata: {
} type: item.Type === "Episode" ? "tvShow" : "movie",
client title: item.Name || "",
.loadMedia({ subtitle: item.Overview || "",
mediaInfo: {
contentUrl: url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
}, },
startTime: 0, },
}) startTime: 0,
.then(() => { });
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
} }
}); });
break; break;
@@ -168,154 +68,18 @@ export const PlayButton: React.FC<Props> = ({ 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 ( return (
<View> <Button
<TouchableOpacity onPress={onPress}
accessibilityLabel="Play button" iconRight={
accessibilityHint="Tap to play the media" <View className="flex flex-row items-center space-x-2">
onPress={onPress} <Ionicons name="play-circle" size={24} color="white" />
className="relative" {client && <Feather name="cast" size={22} color="white" />}
{...props}
>
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View> </View>
}
<Animated.View {...props}
style={[animatedAverageStyle]} >
className="absolute w-full h-full top-0 left-0 rounded-xl" {runtimeTicksToMinutes(item?.RunTimeTicks)}
/> </Button>
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
>
<View className="flex flex-row items-center space-x-2">
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} />
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>
<View className="mt-2 flex flex-row items-center">
<Ionicons
name="information-circle"
size={12}
className=""
color={"#9BA1A6"}
/>
<Text className="text-neutral-500 ml-1">
{directStream ? "Direct stream" : "Transcoded stream"}
</Text>
</View>
</View>
); );
}; };

View File

@@ -7,13 +7,9 @@ import { useQueryClient } from "@tanstack/react-query";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React from "react"; import React from "react";
import { TouchableOpacity, View, ViewProps } from "react-native"; import { TouchableOpacity, View } from "react-native";
interface Props extends ViewProps { export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
item: BaseItemDto;
}
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -41,10 +37,7 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
}; };
return ( return (
<View <View>
className=" bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
{...props}
>
{item.UserData?.Played ? ( {item.UserData?.Played ? (
<TouchableOpacity <TouchableOpacity
onPress={async () => { onPress={async () => {
@@ -58,7 +51,7 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
}} }}
> >
<View className="rounded h-10 aspect-square flex items-center justify-center"> <View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle" size={24} color="white" /> <Ionicons name="checkmark-circle" size={30} color="white" />
</View> </View>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
@@ -74,7 +67,7 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
}} }}
> >
<View className="rounded h-10 aspect-square flex items-center justify-center"> <View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle-outline" size={24} color="white" /> <Ionicons name="checkmark-circle-outline" size={30} color="white" />
</View> </View>
</TouchableOpacity> </TouchableOpacity>
)} )}

View File

@@ -5,13 +5,12 @@ import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; import { Image } from "expo-image";
interface Props extends ViewProps { interface Props extends ViewProps {
item?: BaseItemDto | null; item: BaseItemDto;
} }
export const Ratings: React.FC<Props> = ({ item, ...props }) => { export const Ratings: React.FC<Props> = ({ item }) => {
if (!item) return null;
return ( return (
<View className="flex flex-row items-center mt-2 space-x-2" {...props}> <View className="flex flex-row items-center justify-center mt-2 space-x-2">
{item.OfficialRating && ( {item.OfficialRating && (
<Badge text={item.OfficialRating} variant="gray" /> <Badge text={item.OfficialRating} variant="gray" />
)} )}

View File

@@ -6,26 +6,23 @@ import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router"; import { router } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native"; import { ScrollView, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { ItemCardText } from "./ItemCardText"; import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
interface SimilarItemsProps extends ViewProps { type SimilarItemsProps = {
itemId?: string | null; itemId: string;
} };
export const SimilarItems: React.FC<SimilarItemsProps> = ({ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
itemId,
...props
}) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({ const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({
queryKey: ["similarItems", itemId], queryKey: ["similarItems", itemId],
queryFn: async () => { queryFn: async () => {
if (!api || !user?.Id || !itemId) return []; if (!api || !user?.Id) return [];
const response = await getLibraryApi(api).getSimilarItems({ const response = await getLibraryApi(api).getSimilarItems({
itemId, itemId,
userId: user.Id, userId: user.Id,
@@ -44,8 +41,8 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
); );
return ( return (
<View {...props}> <View>
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text> <Text className="px-4 text-2xl font-bold mb-2">Similar items</Text>
{isLoading ? ( {isLoading ? (
<View className="my-12"> <View className="my-12">
<Loader /> <Loader />
@@ -56,7 +53,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
{movies.map((item) => ( {movies.map((item) => (
<TouchableOpacity <TouchableOpacity
key={item.Id} key={item.Id}
onPress={() => router.push(`/items/page?id=${item.Id}`)} onPress={() => router.push(`/items/${item.Id}`)}
className="flex flex-col w-32" className="flex flex-col w-32"
> >
<MoviePoster item={item} /> <MoviePoster item={item} />
@@ -66,9 +63,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
</View> </View>
</ScrollView> </ScrollView>
)} )}
{movies.length === 0 && ( {movies.length === 0 && <Text className="px-4">No similar items</Text>}
<Text className="px-4 text-neutral-500">No similar items</Text>
)}
</View> </View>
); );
}; };

View File

@@ -2,32 +2,29 @@ import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models"; import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools"; import { tc } from "@/utils/textTools";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo; item: BaseItemDto;
onChange: (value: number) => void; onChange: (value: number) => void;
selected: number; selected: number;
} }
export const SubtitleTrackSelector: React.FC<Props> = ({ export const SubtitleTrackSelector: React.FC<Props> = ({
source, item,
onChange, onChange,
selected, selected,
...props ...props
}) => { }) => {
const [settings] = useSettings();
const subtitleStreams = useMemo( const subtitleStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [], () =>
[source] item.MediaSources?.[0].MediaStreams?.filter(
(x) => x.Type === "Subtitle"
) ?? [],
[item]
); );
const selectedSubtitleSteam = useMemo( const selectedSubtitleSteam = useMemo(
@@ -36,43 +33,31 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
); );
useEffect(() => { useEffect(() => {
// const index = source.DefaultAudioStreamIndex; const index = item.MediaSources?.[0].DefaultSubtitleStreamIndex;
// if (index !== undefined && index !== null) { if (index !== undefined && index !== null) {
// onChange(index); onChange(index);
// return; } else {
// } onChange(-1);
const defaultSubIndex = subtitleStreams?.find(
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
)?.Index;
if (defaultSubIndex !== undefined && defaultSubIndex !== null) {
onChange(defaultSubIndex);
return;
} }
}, []);
onChange(-1);
}, [subtitleStreams, settings]);
if (subtitleStreams.length === 0) return null; if (subtitleStreams.length === 0) return null;
return ( return (
<View <View className="flex flex-row items-center justify-between" {...props}>
className="flex col shrink justify-start place-self-start items-start"
style={{
minWidth: 60,
maxWidth: 200,
}}
>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col " {...props}> <View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Subtitle</Text> <Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <View className="flex flex-row">
<Text className=" "> <TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
{selectedSubtitleSteam <Text className="">
? tc(selectedSubtitleSteam?.DisplayTitle, 7) {selectedSubtitleSteam
: "None"} ? tc(selectedSubtitleSteam?.DisplayTitle, 13)
</Text> : "None"}
</TouchableOpacity> </Text>
</TouchableOpacity>
</View>
</View> </View>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
@@ -84,7 +69,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
collisionPadding={8} collisionPadding={8}
sideOffset={8} sideOffset={8}
> >
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label> <DropdownMenu.Label>Subtitles</DropdownMenu.Label>
<DropdownMenu.Item <DropdownMenu.Item
key={"-1"} key={"-1"}
onSelect={() => { onSelect={() => {

View File

@@ -1,59 +0,0 @@
import {
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "@/components/common/Text";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { BlurView, BlurViewProps } from "expo-blur";
interface Props extends BlurViewProps {
background?: "blur" | "transparent";
touchableOpacityProps?: TouchableOpacityProps;
}
export const HeaderBackButton: React.FC<Props> = ({
background = "transparent",
touchableOpacityProps,
...props
}) => {
const router = useRouter();
if (background === "transparent")
return (
<BlurView
{...props}
intensity={100}
className="overflow-hidden rounded-full p-2"
>
<TouchableOpacity
onPress={() => router.back()}
{...touchableOpacityProps}
>
<Ionicons
className="drop-shadow-2xl"
name="arrow-back"
size={24}
color="white"
/>
</TouchableOpacity>
</BlurView>
);
return (
<TouchableOpacity
onPress={() => router.back()}
className=" bg-neutral-800/80 rounded-full p-2"
{...touchableOpacityProps}
>
<Ionicons
className="drop-shadow-2xl"
name="arrow-back"
size={24}
color="#077DF2"
/>
</TouchableOpacity>
);
};

View File

@@ -1,14 +1,16 @@
import { FlashList, FlashListProps } from "@shopify/flash-list"; import { FlashList, FlashListProps } from "@shopify/flash-list";
import React, { forwardRef, useImperativeHandle, useRef } from "react"; import React, { useEffect } from "react";
import { View, ViewStyle } from "react-native"; import { View, ViewStyle } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Loader } from "../Loader";
import { Text } from "./Text"; import { Text } from "./Text";
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>; type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
export interface HorizontalScrollRef {
scrollToIndex: (index: number, viewOffset: number) => void;
}
interface HorizontalScrollProps<T> interface HorizontalScrollProps<T>
extends PartialExcept< extends PartialExcept<
Omit<FlashListProps<T>, "renderItem">, Omit<FlashListProps<T>, "renderItem">,
@@ -21,69 +23,61 @@ interface HorizontalScrollProps<T>
loadingContainerStyle?: ViewStyle; loadingContainerStyle?: ViewStyle;
height?: number; height?: number;
loading?: boolean; loading?: boolean;
extraData?: any;
} }
export const HorizontalScroll = forwardRef< export function HorizontalScroll<T>({
HorizontalScrollRef, data = [],
HorizontalScrollProps<any> renderItem,
>( containerStyle,
<T,>( contentContainerStyle,
{ loadingContainerStyle,
data = [], loading = false,
renderItem, height = 164,
containerStyle, ...props
contentContainerStyle, }: HorizontalScrollProps<T>): React.ReactElement {
loadingContainerStyle, const animatedOpacity = useSharedValue(0);
loading = false, const animatedStyle1 = useAnimatedStyle(() => {
height = 164, return {
extraData, opacity: withTiming(animatedOpacity.value, { duration: 250 }),
...props };
}: HorizontalScrollProps<T>, });
ref: React.ForwardedRef<HorizontalScrollRef>
) => {
const flashListRef = useRef<FlashList<T>>(null);
useImperativeHandle(ref!, () => ({ useEffect(() => {
scrollToIndex: (index: number, viewOffset: number) => { if (data) {
flashListRef.current?.scrollToIndex({ animatedOpacity.value = 1;
index, }
animated: true, }, [data]);
viewPosition: 0,
viewOffset,
});
},
}));
const renderFlashListItem = ({ if (data === undefined || data === null || loading) {
item, return (
index, <View
}: { style={[
item: T; {
index: number; flex: 1,
}) => ( justifyContent: "center",
<View className="mr-2"> alignItems: "center",
<React.Fragment>{renderItem(item, index)}</React.Fragment> },
loadingContainerStyle,
]}
>
<Loader />
</View> </View>
); );
}
if (!data || loading) { const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
return ( <View className="mr-2">
<View className="px-4 mb-2"> <React.Fragment>{renderItem(item, index)}</React.Fragment>
<View className="bg-neutral-950 h-24 w-full rounded-md mb-2"></View> </View>
<View className="bg-neutral-950 h-10 w-full rounded-md mb-1"></View> );
</View>
);
}
return ( return (
<FlashList<T> <Animated.View style={[containerStyle, animatedStyle1]}>
ref={flashListRef} <FlashList
data={data} data={data}
extraData={extraData}
renderItem={renderFlashListItem} renderItem={renderFlashListItem}
horizontal horizontal
estimatedItemSize={200} estimatedItemSize={100}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: 16, paddingHorizontal: 16,
@@ -96,6 +90,6 @@ export const HorizontalScroll = forwardRef<
)} )}
{...props} {...props}
/> />
); </Animated.View>
} );
); }

View File

@@ -1,87 +0,0 @@
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getItemImage } from "@/utils/getItemImage";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image, ImageProps, ImageSource } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
interface Props extends ImageProps {
item: BaseItemDto;
variant?:
| "Primary"
| "Backdrop"
| "ParentBackdrop"
| "ParentLogo"
| "Logo"
| "AlbumPrimary"
| "SeriesPrimary"
| "Screenshot"
| "Thumb";
quality?: number;
width?: number;
useThemeColor?: boolean;
onError?: () => void;
}
export const ItemImage: React.FC<Props> = ({
item,
variant = "Primary",
quality = 90,
width = 1000,
useThemeColor = false,
onError,
...props
}) => {
const [api] = useAtom(apiAtom);
const source = useMemo(() => {
if (!api) {
onError && onError();
return;
}
return getItemImage({
item,
api,
variant,
quality,
width,
});
}, [api, item, quality, variant, width]);
// return placeholder icon if no source
if (!source?.uri)
return (
<View
{...props}
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
>
<Ionicons
name="image-outline"
size={24}
color="white"
style={{ opacity: 0.4 }}
/>
</View>
);
return (
<Image
cachePolicy={"memory-disk"}
transition={300}
placeholder={{
blurhash: source?.blurhash,
}}
style={{
width: "100%",
height: "100%",
}}
source={{
uri: source?.uri,
}}
{...props}
/>
);
};

View File

@@ -1,8 +1,14 @@
import {
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "@/components/common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics";
import { useRouter, useSegments } from "expo-router";
import { PropsWithChildren } from "react"; import { PropsWithChildren } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native"; import { useRouter } from "expo-router";
import * as Haptics from "expo-haptics";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
@@ -14,67 +20,46 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
...props ...props
}) => { }) => {
const router = useRouter(); const router = useRouter();
const segments = useSegments(); return (
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const from = segments[2]; if (item.Type === "Series") {
router.push(`/series/${item.Id}`);
return;
}
if (item.Type === "Episode") {
router.push(`/items/${item.Id}`);
return;
}
if (item.Type === "MusicAlbum") {
router.push(`/albums/${item.Id}`);
return;
}
if (item.Type === "Audio") {
router.push(`/albums/${item.AlbumId}`);
return;
}
if (item.Type === "MusicArtist") {
router.push(`/artists/${item.Id}/page`);
return;
}
if (item.Type === "Person") {
router.push(`/actors/${item.Id}`);
return;
}
if (from === "(home)" || from === "(search)" || from === "(libraries)") if (item.Type === "BoxSet") {
return ( router.push(`/collections/${item.Id}`);
<TouchableOpacity return;
onPress={() => { }
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (item.Type === "Series") { router.push(`/items/${item.Id}`);
router.push(`/(auth)/(tabs)/${from}/series/${item.Id}`); }}
return; {...props}
} >
{children}
if (item.Type === "MusicAlbum") { </TouchableOpacity>
router.push(`/(auth)/(tabs)/${from}/albums/${item.Id}`); );
return;
}
if (item.Type === "Audio") {
router.push(`/(auth)/(tabs)/${from}/albums/${item.AlbumId}`);
return;
}
if (item.Type === "MusicArtist") {
router.push(`/(auth)/(tabs)/${from}/artists/${item.Id}`);
return;
}
if (item.Type === "Person") {
router.push(`/(auth)/(tabs)/${from}/actors/${item.Id}`);
return;
}
if (item.Type === "BoxSet") {
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
return;
}
if (item.Type === "UserView") {
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
return;
}
if (item.Type === "CollectionFolder") {
router.push(`/(auth)/(tabs)/(libraries)/${item.Id}`);
return;
}
// Same as default
// if (item.Type === "Episode") {
// router.push(`/items/${item.Id}`);
// return;
// }
router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`);
}}
{...props}
>
{children}
</TouchableOpacity>
);
}; };

View File

@@ -23,14 +23,14 @@ interface EpisodeCardProps {
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => { export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
const { deleteFile } = useFiles(); const { deleteFile } = useFiles();
const { startDownloadedFilePlayback } = usePlayback(); const { setCurrentlyPlayingState } = usePlayback();
const handleOpenFile = useCallback(async () => { const handleOpenFile = useCallback(async () => {
startDownloadedFilePlayback({ setCurrentlyPlayingState({
item, item,
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`, url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
}); });
}, [item, startDownloadedFilePlayback]); }, [item, setCurrentlyPlayingState]);
/** /**
* Handles deleting the file with haptic feedback. * Handles deleting the file with haptic feedback.

View File

@@ -26,14 +26,14 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useFiles(); const { deleteFile } = useFiles();
const [settings] = useSettings(); const [settings] = useSettings();
const { startDownloadedFilePlayback } = usePlayback(); const { setCurrentlyPlayingState } = usePlayback();
const handleOpenFile = useCallback(() => { const handleOpenFile = useCallback(() => {
startDownloadedFilePlayback({ setCurrentlyPlayingState({
item, item,
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`, url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
}); });
}, [item, startDownloadedFilePlayback]); }, [item, setCurrentlyPlayingState]);
/** /**
* Handles deleting the file with haptic feedback. * Handles deleting the file with haptic feedback.

View File

@@ -25,7 +25,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
return ( return (
<View> <View>
<View className="flex flex-row items-center justify-between"> <View className="flex flex-row items-center justify-between">
<Text className="text-2xl font-bold shrink">{items[0].SeriesName}</Text> <Text className="text-2xl font-bold">{items[0].SeriesName}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center"> <View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{items.length}</Text> <Text className="text-xs font-bold">{items.length}</Text>
</View> </View>

View File

@@ -23,7 +23,7 @@ export const FilterButton = <T,>({
queryFn, queryFn,
queryKey, queryKey,
set, set,
values, // selected values values,
title, title,
renderItemLabel, renderItemLabel,
searchFilter, searchFilter,

View File

@@ -186,7 +186,7 @@ export const FilterSheet = <T,>({
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between" className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
> >
<Text>{renderItemLabel(item)}</Text> <Text>{renderItemLabel(item)}</Text>
{values.some((i) => i === item) ? ( {values.includes(item) ? (
<Ionicons name="radio-button-on" size={24} color="white" /> <Ionicons name="radio-button-on" size={24} color="white" />
) : ( ) : (
<Ionicons name="radio-button-off" size={24} color="white" /> <Ionicons name="radio-button-off" size={24} color="white" />

View File

@@ -0,0 +1,93 @@
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { TouchableOpacity, View, ViewProps } from "react-native";
import {
sortByAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
} from "@/utils/atoms/filters";
interface Props extends ViewProps {
title: string;
}
export const SortButton: React.FC<Props> = ({ title, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity>
<View
className={`
px-3 py-2 rounded-full flex flex-row items-center space-x-2 bg-neutral-900
`}
{...props}
>
<Text>Sort by</Text>
<Ionicons
name="filter"
size={16}
color="white"
style={{ opacity: 0.5 }}
/>
</View>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
{sortOptions?.map((g) => (
<DropdownMenu.CheckboxItem
value={sortBy.key === g.key ? "on" : "off"}
onValueChange={(next, previous) => {
if (next === "on") {
setSortBy(g);
} else {
setSortBy(sortOptions[0]);
}
}}
key={g.key}
textValue={g.value}
>
<DropdownMenu.ItemIndicator />
</DropdownMenu.CheckboxItem>
))}
<DropdownMenu.Separator />
<DropdownMenu.Group>
{sortOrderOptions.map((g) => (
<DropdownMenu.CheckboxItem
value={sortOrder.key === g.key ? "on" : "off"}
onValueChange={(next, previous) => {
if (next === "on") {
setSortOrder(g);
} else {
setSortOrder(sortOrderOptions[0]);
}
}}
key={g.key}
textValue={g.value}
>
<DropdownMenu.ItemIndicator />
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

View File

@@ -47,7 +47,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
return response.data.Items?.[0].Id || null; return response.data.Items?.[0].Id || null;
}, },
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true, enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 60 * 1000, staleTime: 0,
}); });
const onPressPagination = (index: number) => { const onPressPagination = (index: number) => {
@@ -75,7 +75,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
return response.data.Items || []; return response.data.Items || [];
}, },
enabled: !!api && !!user?.Id && !!sf_carousel, enabled: !!api && !!user?.Id && !!sf_carousel,
staleTime: 60 * 1000, staleTime: 0,
}); });
const width = Dimensions.get("screen").width; const width = Dimensions.get("screen").width;

View File

@@ -1,78 +1,55 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { useSettings } from "@/utils/atoms/settings";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
useQuery,
type QueryFunction,
type QueryKey,
} from "@tanstack/react-query";
import { View, ViewProps } from "react-native"; import { View, ViewProps } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText"; import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll"; import { HorizontalScroll } from "../common/HorrizontalScroll";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import SeriesPoster from "../posters/SeriesPoster";
interface Props extends ViewProps { interface Props extends ViewProps {
title?: string | null; title: string;
loading?: boolean;
orientation?: "horizontal" | "vertical"; orientation?: "horizontal" | "vertical";
data?: BaseItemDto[] | null;
height?: "small" | "large"; height?: "small" | "large";
disabled?: boolean; disabled?: boolean;
queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[]>;
} }
export const ScrollingCollectionList: React.FC<Props> = ({ export const ScrollingCollectionList: React.FC<Props> = ({
title, title,
data,
orientation = "vertical", orientation = "vertical",
height = "small", height = "small",
loading = false,
disabled = false, disabled = false,
queryFn,
queryKey,
...props ...props
}) => { }) => {
const [settings] = useSettings(); if (disabled) return null;
const { data, isLoading } = useQuery({
queryKey,
queryFn,
enabled: !disabled,
staleTime: 60 * 1000,
});
if (disabled || !title) return null;
return ( return (
<View {...props}> <View {...props}>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100"> <Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
{title} {title}
</Text> </Text>
<HorizontalScroll <HorizontalScroll<BaseItemDto>
data={data} data={data}
height={orientation === "vertical" ? 247 : 164} height={orientation === "vertical" ? 247 : 164}
loading={isLoading} loading={loading}
renderItem={(item, index) => ( renderItem={(item, index) => (
<TouchableItemRouter <TouchableItemRouter
key={index} key={index}
item={item} item={item}
className={`flex flex-col className={`flex flex-col
${orientation === "horizontal" ? "w-44" : "w-28"} ${orientation === "vertical" ? "w-28" : "w-44"}
`} `}
> >
<View> <View>
{item.Type === "Episode" && orientation === "horizontal" && ( {orientation === "vertical" ? (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} />
)}
{item.Type === "Movie" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Movie" && orientation === "vertical" && (
<MoviePoster item={item} /> <MoviePoster item={item} />
) : (
<ContinueWatchingPoster item={item} />
)} )}
{item.Type === "Series" && <SeriesPoster item={item} />}
<ItemCardText item={item} /> <ItemCardText item={item} />
</View> </View>
</TouchableItemRouter> </TouchableItemRouter>

View File

@@ -1,207 +0,0 @@
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { Ionicons } from "@expo/vector-icons";
import {
BaseItemDto,
CollectionType,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { TouchableOpacityProps, View } from "react-native";
import { getColors } from "react-native-image-colors";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
interface Props extends TouchableOpacityProps {
library: BaseItemDto;
}
type LibraryColor = {
dominantColor: string;
averageColor: string;
secondary: string;
};
type IconName = React.ComponentProps<typeof Ionicons>["name"];
const icons: Record<CollectionType, IconName> = {
movies: "film",
tvshows: "tv",
music: "musical-notes",
books: "book",
homevideos: "videocam",
boxsets: "albums",
playlists: "list",
folders: "folder",
livetv: "tv",
musicvideos: "musical-notes",
photos: "images",
trailers: "videocam",
unknown: "help-circle",
} as const;
export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [settings] = useSettings();
const [imageInfo, setImageInfo] = useState<LibraryColor>({
dominantColor: "#fff",
averageColor: "#fff",
secondary: "#fff",
});
const url = useMemo(
() =>
getPrimaryImageUrl({
api,
item: library,
}),
[library]
);
const { data: itemsCount } = useQuery({
queryKey: ["library-count", library.Id],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: library.Id,
limit: 0,
});
return response.data.TotalRecordCount;
},
});
useEffect(() => {
if (url) {
getColors(url, {
fallback: "#fff",
cache: true,
key: url,
})
.then((colors) => {
let dominantColor: string = "#fff";
let averageColor: string = "#fff";
let secondary: string = "#fff";
if (colors.platform === "android") {
dominantColor = colors.dominant;
averageColor = colors.average;
secondary = colors.muted;
} else if (colors.platform === "ios") {
dominantColor = colors.primary;
averageColor = colors.background;
secondary = colors.detail;
}
setImageInfo({
dominantColor,
averageColor,
secondary,
});
})
.catch((error) => {
console.error("Error getting colors", error);
});
}
}, [url]);
if (!url) return null;
if (settings?.libraryOptions?.display === "row") {
return (
<TouchableItemRouter item={library} className="w-full px-4">
<View className="flex flex-row items-center w-full relative ">
<Ionicons
name={icons[library.CollectionType!] || "folder"}
size={22}
color={"#e5e5e5"}
/>
<Text className="text-start px-4 text-neutral-200">
{library.Name}
</Text>
{settings?.libraryOptions?.showStats && (
<Text className="font-bold text-xs text-neutral-500 text-start px-4 ml-auto">
{itemsCount} items
</Text>
)}
</View>
</TouchableItemRouter>
);
}
if (settings?.libraryOptions?.imageStyle === "cover") {
return (
<TouchableItemRouter item={library} className="w-full">
<View className="flex justify-center rounded-xl w-full relative border border-neutral-900 h-20 ">
<View
style={{
width: "100%",
height: "100%",
borderRadius: 8,
position: "absolute",
top: 0,
left: 0,
overflow: "hidden",
}}
>
<Image
source={{ uri: url }}
style={{
width: "100%",
height: "100%",
}}
/>
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.3)", // Adjust the alpha value (0.3) to control darkness
}}
/>
</View>
{settings?.libraryOptions?.showTitles && (
<Text className="font-bold text-lg text-start px-4">
{library.Name}
</Text>
)}
{settings?.libraryOptions?.showStats && (
<Text className="font-bold text-xs text-start px-4">
{itemsCount} items
</Text>
)}
</View>
</TouchableItemRouter>
);
}
return (
<TouchableItemRouter item={library} {...props}>
<View className="flex flex-row items-center justify-between rounded-xl w-full relative border bg-neutral-900 border-neutral-900 h-20">
<View className="flex flex-col">
<Text className="font-bold text-lg text-start px-4">
{library.Name}
</Text>
{settings?.libraryOptions?.showStats && (
<Text className="font-bold text-xs text-neutral-500 text-start px-4">
{itemsCount} items
</Text>
)}
</View>
<View className="p-2">
<Image
source={{ uri: url }}
className="h-full aspect-[2/1] object-cover rounded-lg overflow-hidden"
/>
</View>
</View>
</TouchableItemRouter>
);
};

View File

@@ -12,38 +12,22 @@ import { Text } from "../common/Text";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText"; import { ItemCardText } from "../ItemCardText";
import MoviePoster from "../posters/MoviePoster"; import MoviePoster from "../posters/MoviePoster";
import {
type QueryKey,
type QueryFunction,
useQuery,
} from "@tanstack/react-query";
interface Props extends ViewProps { interface Props extends ViewProps {
queryKey: QueryKey; collection: BaseItemDto;
queryFn: QueryFunction<BaseItemDto>;
} }
export const MediaListSection: React.FC<Props> = ({ export const MediaListSection: React.FC<Props> = ({ collection, ...props }) => {
queryFn,
queryKey,
...props
}) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { data: collection, isLoading } = useQuery({
queryKey,
queryFn,
staleTime: 60 * 1000,
});
const fetchItems = useCallback( const fetchItems = useCallback(
async ({ async ({
pageParam, pageParam,
}: { }: {
pageParam: number; pageParam: number;
}): Promise<BaseItemDtoQueryResult | null> => { }): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !user?.Id || !collection) return null; if (!api || !user?.Id) return null;
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
userId: user.Id, userId: user.Id,
@@ -54,7 +38,7 @@ export const MediaListSection: React.FC<Props> = ({
return response.data; return response.data;
}, },
[api, user?.Id, collection?.Id] [api, user?.Id, collection.Id]
); );
if (!collection) return null; if (!collection) return null;

View File

@@ -1,16 +1,17 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useRouter } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View, ViewProps } from "react-native";
interface Props extends ViewProps { interface Props extends ViewProps {
item: BaseItemDto; item: BaseItemDto;
} }
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => { export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
const router = useRouter();
return ( return (
<View {...props}> <View className="flex flex-row items-center self-center px-4" {...props}>
<Text className=" font-bold text-2xl mb-1">{item?.Name}</Text> <Text className="text-center font-bold text-2xl mr-2">{item?.Name}</Text>
<Text className=" opacity-50">{item?.ProductionYear}</Text>
</View> </View>
); );
}; };

View File

@@ -71,10 +71,7 @@ export const SongsListItem: React.FC<Props> = ({
}; };
const play = async (type: "device" | "cast") => { const play = async (type: "device" | "cast") => {
if (!user?.Id || !api || !item.Id) { if (!user?.Id || !api || !item.Id) return;
console.warn("No user, api or item", user, api, item.Id);
return;
}
const response = await getMediaInfoApi(api!).getPlaybackInfo({ const response = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: item?.Id, itemId: item?.Id,
@@ -90,13 +87,9 @@ export const SongsListItem: React.FC<Props> = ({
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0, startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
sessionData, sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios, deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
mediaSourceId: item.Id,
}); });
if (!url || !item) { if (!url || !item) return;
console.warn("No url or item", url, item.Id);
return;
}
if (type === "cast" && client) { if (type === "cast" && client) {
await CastContext.getPlayServicesState().then((state) => { await CastContext.getPlayServicesState().then((state) => {
@@ -118,7 +111,6 @@ export const SongsListItem: React.FC<Props> = ({
} }
}); });
} else { } else {
console.log("Playing on device", url, item.Id);
setCurrentlyPlayingState({ setCurrentlyPlayingState({
item, item,
url, url,

View File

@@ -1,64 +0,0 @@
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<MoviePosterProps> = ({
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 (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
width: "100%",
}}
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className="h-1 bg-red-600 w-full"></View>
)}
</View>
);
};

View File

@@ -1,53 +0,0 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { ItemImage } from "../common/ItemImage";
import { WatchedIndicator } from "../WatchedIndicator";
import { useState } from "react";
interface Props extends ViewProps {
item: BaseItemDto;
showProgress?: boolean;
}
export const ItemPoster: React.FC<Props> = ({
item,
showProgress,
...props
}) => {
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
);
if (item.Type === "Movie" || item.Type === "Series" || item.Type === "BoxSet")
return (
<View
className="relative rounded-lg overflow-hidden border border-neutral-900"
{...props}
>
<ItemImage
style={{
aspectRatio: "10/15",
width: "100%",
}}
item={item}
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className="h-1 bg-red-600 w-full"></View>
)}
</View>
);
return (
<View
className="rounded-lg w-full aspect-square overflow-hidden border border-neutral-900"
{...props}
>
<ItemImage className="w-full aspect-square" item={item} />
</View>
);
};

View File

@@ -1,4 +1,3 @@
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
@@ -6,6 +5,7 @@ import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
type MoviePosterProps = { type MoviePosterProps = {
item: BaseItemDto; item: BaseItemDto;
@@ -18,13 +18,15 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const url = useMemo(() => { const url = useMemo(
return getPrimaryImageUrl({ () =>
api, getPrimaryImageUrl({
item, api,
width: 300, item,
}); width: 300,
}, [item]); }),
[item]
);
const [progress, setProgress] = useState( const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0 item.UserData?.PlayedPercentage || 0
@@ -57,7 +59,6 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
width: "100%", width: "100%",
}} }}
/> />
<WatchedIndicator item={item} /> <WatchedIndicator item={item} />
{showProgress && progress > 0 && ( {showProgress && progress > 0 && (
<View className="h-1 bg-red-600 w-full"></View> <View className="h-1 bg-red-600 w-full"></View>

View File

@@ -15,16 +15,14 @@ type MoviePosterProps = {
const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => { const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const url = useMemo(() => { const url = useMemo(
if (item.Type === "Episode") { () =>
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=389&quality=80&tag=${item.SeriesPrimaryImageTag}`; getPrimaryImageUrl({
} api,
return getPrimaryImageUrl({ item,
api, }),
item, [item]
width: 300, );
});
}, [item]);
const blurhash = useMemo(() => { const blurhash = useMemo(() => {
const key = item.ImageTags?.["Primary"] as string; const key = item.ImageTags?.["Primary"] as string;

View File

@@ -1,31 +1,30 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { import {
BaseItemDto, BaseItemDto,
BaseItemPerson, BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { useAtom } from "jotai";
import React from "react"; import React from "react";
import { TouchableOpacity, View, ViewProps } from "react-native"; import { Linking, TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll"; import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import Poster from "../posters/Poster"; 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";
interface Props extends ViewProps { export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
item?: BaseItemDto | null;
loading?: boolean;
}
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [settings] = useSettings();
const pathname = usePathname();
return ( return (
<View {...props} className="flex flex-col"> <View>
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text> <Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
<HorizontalScroll <HorizontalScroll<NonNullable<BaseItemPerson>>
loading={loading} data={item.People}
data={item?.People || []}
renderItem={(item, index) => ( renderItem={(item, index) => (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {

View File

@@ -3,23 +3,19 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router"; import { router } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React from "react"; import React from "react";
import { TouchableOpacity, View, ViewProps } from "react-native"; import { TouchableOpacity, View } from "react-native";
import Poster from "../posters/Poster"; import Poster from "../posters/Poster";
import { HorizontalScroll } from "../common/HorrizontalScroll"; import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
interface Props extends ViewProps { export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
item?: BaseItemDto | null;
}
export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
return ( return (
<View {...props}> <View>
<Text className="text-lg font-bold mb-2 px-4">Series</Text> <Text className="text-lg font-bold mb-2 px-4">Series</Text>
<HorizontalScroll <HorizontalScroll<BaseItemDto>
data={[item]} data={[item]}
renderItem={(item, index) => ( renderItem={(item, index) => (
<TouchableOpacity <TouchableOpacity

View File

@@ -1,34 +0,0 @@
import { Text } from "@/components/common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter } from "expo-router";
import { TouchableOpacity, View, ViewProps } from "react-native";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
const router = useRouter();
return (
<View {...props}>
<Text className="font-bold text-2xl">{item?.Name}</Text>
<View className="flex flex-row items-center mb-1">
<TouchableOpacity
onPress={() => {
router.push(
// @ts-ignore
`/(auth)/series/${item.SeriesId}?seasonIndex=${item?.ParentIndexNumber}`
);
}}
>
<Text className="opacity-50">{item?.SeasonName}</Text>
</TouchableOpacity>
<Text className="opacity-50 mx-2">{"—"}</Text>
<Text className="opacity-50">{`Episode ${item.IndexNumber}`}</Text>
</View>
<Text className="opacity-50">{item?.ProductionYear}</Text>
</View>
);
};

View File

@@ -23,6 +23,40 @@ export const NextEpisodeButton: React.FC<Props> = ({
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); 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({ const { data: nextEpisode } = useQuery({
queryKey: ["nextEpisode", item.Id, item.ParentId, type], queryKey: ["nextEpisode", item.Id, item.ParentId, type],
queryFn: async () => { queryFn: async () => {
@@ -56,7 +90,7 @@ export const NextEpisodeButton: React.FC<Props> = ({
return ( return (
<Button <Button
onPress={() => router.setParams({ id: nextEpisode?.Id })} onPress={() => router.replace(`/items/${nextEpisode?.Id}`)}
className={`h-12 aspect-square`} className={`h-12 aspect-square`}
disabled={disabled} disabled={disabled}
{...props} {...props}

View File

@@ -34,7 +34,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
if (!items?.length) if (!items?.length)
return ( return (
<View className="px-4"> <View>
<Text className="text-lg font-bold mb-2">Next up</Text> <Text className="text-lg font-bold mb-2">Next up</Text>
<Text className="opacity-50">No items to display</Text> <Text className="opacity-50">No items to display</Text>
</View> </View>
@@ -43,17 +43,17 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
return ( return (
<View> <View>
<Text className="text-lg font-bold mb-2 px-4">Next up</Text> <Text className="text-lg font-bold mb-2 px-4">Next up</Text>
<HorizontalScroll <HorizontalScroll<BaseItemDto>
data={items} data={items}
renderItem={(item, index) => ( renderItem={(item, index) => (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
router.push(`/(auth)/items/page?id=${item.Id}`); router.push(`/(auth)/items/${item.Id}`);
}} }}
key={item.Id} key={item.Id}
className="flex flex-col w-44" className="flex flex-col w-44"
> >
<ContinueWatchingPoster item={item} useEpisodePoster /> <ContinueWatchingPoster item={item} />
<ItemCardText item={item} /> <ItemCardText item={item} />
</TouchableOpacity> </TouchableOpacity>
)} )}

View File

@@ -1,143 +0,0 @@
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<Props> = ({
item,
loading,
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const scrollRef = useRef<HorizontalScrollRef>(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 (
<HorizontalScroll
ref={scrollRef}
data={episodes}
extraData={item}
loading={loading || isLoading || isFetching}
renderItem={(_item, idx) => (
<TouchableOpacity
key={_item.Id}
onPress={() => {
router.setParams({ id: _item.Id });
}}
className={`flex flex-col w-44
${item?.Id === _item.Id ? "" : "opacity-50"}
`}
>
<ContinueWatchingPoster item={_item} useEpisodePoster />
<ItemCardText item={_item} />
</TouchableOpacity>
)}
{...props}
/>
);
};

View File

@@ -1,7 +1,7 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToSeconds } from "@/utils/time"; import { runtimeTicksToSeconds } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@@ -11,14 +11,9 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { DownloadItem } from "../DownloadItem"; import { DownloadItem } from "../DownloadItem";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { Text } from "../common/Text"; 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 = { type Props = {
item: BaseItemDto; item: BaseItemDto;
initialSeasonIndex?: number;
}; };
type SeasonIndexState = { type SeasonIndexState = {
@@ -27,7 +22,7 @@ type SeasonIndexState = {
export const seasonIndexAtom = atom<SeasonIndexState>({}); export const seasonIndexAtom = atom<SeasonIndexState>({});
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => { export const SeasonPicker: React.FC<Props> = ({ item }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
@@ -62,35 +57,15 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
useEffect(() => { useEffect(() => {
if (seasons && seasons.length > 0 && seasonIndex === undefined) { if (seasons && seasons.length > 0 && seasonIndex === undefined) {
let initialIndex: number | undefined; const firstSeason = seasons[0];
if (firstSeason.IndexNumber !== 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) => ({ setSeasonIndexState((prev) => ({
...prev, ...prev,
[item.Id ?? ""]: initialIndex, [item.Id ?? ""]: firstSeason.IndexNumber,
})); }));
} }
} }
}, [seasons, seasonIndex, setSeasonIndexState, item.Id, initialSeasonIndex]); }, [seasons, seasonIndex, setSeasonIndexState, item.Id]);
const selectedSeasonId: string | null = useMemo( const selectedSeasonId: string | null = useMemo(
() => () =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id, seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
@@ -100,39 +75,27 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
const { data: episodes, isFetching } = useQuery({ const { data: episodes, isFetching } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId], queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => { queryFn: async () => {
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return []; if (!api || !user?.Id || !item.Id) return [];
const res = await getTvShowsApi(api).getEpisodes({ const response = await api.axiosInstance.get(
seriesId: item.Id, `${api.basePath}/Shows/${item.Id}/Episodes`,
userId: user.Id, {
seasonId: selectedSeasonId, params: {
enableUserData: true, userId: user?.Id,
fields: ["MediaSources", "MediaStreams", "Overview"], seasonId: selectedSeasonId,
}); Fields:
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
return res.data.Items; return response.data.Items as BaseItemDto[];
}, },
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, 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 // Used for height calculation
const [nrOfEpisodes, setNrOfEpisodes] = useState(0); const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
useEffect(() => { useEffect(() => {
@@ -180,6 +143,26 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
))} ))}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
{/* Old View. Might have a setting later to manually select view. */}
{/* {episodes && (
<View className="mt-4">
<HorizontalScroll<BaseItemDto>
data={episodes}
renderItem={(item, index) => (
<TouchableOpacity
key={item.Id}
onPress={() => {
router.push(`/(auth)/items/${item.Id}`);
}}
className="flex flex-col w-48"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
)}
/>
</View>
)} */}
<View className="px-4 flex flex-col my-4"> <View className="px-4 flex flex-col my-4">
{isFetching ? ( {isFetching ? (
<View <View
@@ -195,17 +178,13 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
<TouchableOpacity <TouchableOpacity
key={e.Id} key={e.Id}
onPress={() => { onPress={() => {
router.push(`/(auth)/items/page?id=${e.Id}`); router.push(`/(auth)/items/${e.Id}`);
}} }}
className="flex flex-col mb-4" className="flex flex-col mb-4"
> >
<View className="flex flex-row items-center mb-2"> <View className="flex flex-row items-center mb-2">
<View className="w-32 aspect-video overflow-hidden mr-2"> <View className="w-32 aspect-video overflow-hidden mr-2">
<ContinueWatchingPoster <ContinueWatchingPoster item={e} width={128} />
item={e}
width={128}
useEpisodePoster
/>
</View> </View>
<View className="shrink"> <View className="shrink">
<Text numberOfLines={2} className=""> <Text numberOfLines={2} className="">

View File

@@ -0,0 +1,37 @@
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<Props> = ({ item, ...props }) => {
const router = useRouter();
return (
<>
<TouchableOpacity
onPress={() => router.push(`/(auth)/series/${item.SeriesId}`)}
>
<Text className="text-center opacity-50">{item?.SeriesName}</Text>
</TouchableOpacity>
<View className="flex flex-row items-center self-center px-4">
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
</View>
<View>
<View className="flex flex-row items-center self-center">
<TouchableOpacity onPress={() => {}}>
<Text className="text-center opacity-50">{item?.SeasonName}</Text>
</TouchableOpacity>
<Text className="text-center opacity-50 mx-2">{"—"}</Text>
<Text className="text-center opacity-50">
{`Episode ${item.IndexNumber}`}
</Text>
</View>
</View>
</>
);
};

View File

@@ -1,150 +0,0 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
DefaultLanguageOption,
DownloadOptions,
useSettings,
} from "@/utils/atoms/settings";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import {
Linking,
Switch,
TouchableOpacity,
View,
ViewProps,
} from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { Input } from "../common/Input";
import { useState } from "react";
import { Button } from "../Button";
const LANGUAGES: DefaultLanguageOption[] = [
{ label: "eng", value: "eng" },
{
label: "sv",
value: "sv",
},
];
interface Props extends ViewProps {}
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
return (
<View>
<Text className="text-lg font-bold mb-2">Media</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Audio language</Text>
<Text className="text-xs opacity-50">
Choose a default audio language.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings?.defaultAudioLanguage?.label || "None"}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-audio"}
onSelect={() => {
updateSettings({
defaultAudioLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{LANGUAGES.map((l) => (
<DropdownMenu.Item
key={l.value}
onSelect={() => {
updateSettings({
defaultAudioLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Subtitle language</Text>
<Text className="text-xs opacity-50">
Choose a default subtitle language.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{settings?.defaultSubtitleLanguage?.label || "None"}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-subs"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{LANGUAGES.map((l) => (
<DropdownMenu.Item
key={l.value}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
</View>
</View>
);
};

View File

@@ -1,9 +1,5 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import { DownloadOptions, useSettings } from "@/utils/atoms/settings";
DefaultLanguageOption,
DownloadOptions,
useSettings,
} from "@/utils/atoms/settings";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -14,7 +10,6 @@ import { Loader } from "../Loader";
import { Input } from "../common/Input"; import { Input } from "../common/Input";
import { useState } from "react"; import { useState } from "react";
import { Button } from "../Button"; import { Button } from "../Button";
import { MediaToggles } from "./MediaToggles";
export const SettingToggles: React.FC = () => { export const SettingToggles: React.FC = () => {
const [settings, updateSettings] = useSettings(); const [settings, updateSettings] = useSettings();
@@ -49,302 +44,314 @@ export const SettingToggles: React.FC = () => {
}); });
return ( return (
<View> <View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
{/* <View> <View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<Text className="text-lg font-bold mb-2">Look and feel</Text> <View className="shrink">
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50"> <Text className="font-semibold">Auto rotate</Text>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4"> <Text className="text-xs opacity-50">
<View className="shrink"> Important on android since the video player orientation is locked to
<Text className="font-semibold">Coming soon</Text> the app orientation.
<Text className="text-xs opacity-50 max-w-[90%]"> </Text>
Options for changing the look and feel of the app.
</Text>
</View>
<Switch disabled />
</View>
</View> </View>
</View> */} <Switch
value={settings?.autoRotate}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Download quality</Text>
<Text className="text-xs opacity-50">
Choose the search engine you want to use.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings?.downloadQuality?.label}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Quality</DropdownMenu.Label>
{DownloadOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
onSelect={() => {
updateSettings({ downloadQuality: option });
}}
>
<DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Start videos in fullscreen</Text>
<Text className="text-xs opacity-50">
Clicking a video will start it in fullscreen mode, instead of
inline.
</Text>
</View>
<Switch
value={settings?.openFullScreenVideoPlayerByDefault}
onValueChange={(value) =>
updateSettings({ openFullScreenVideoPlayerByDefault: value })
}
/>
</View>
<MediaToggles /> <View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<View> <Text className="font-semibold">Use external player (VLC)</Text>
<Text className="text-lg font-bold mb-2">Other</Text> <Text className="text-xs opacity-50 shrink">
Open all videos in VLC instead of the default player. This requries
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800"> VLC to be installed on the phone.
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4"> </Text>
<View className="shrink"> </View>
<Text className="font-semibold">Auto rotate</Text> <Switch
<Text className="text-xs opacity-50"> value={settings?.openInVLC}
Important on android since the video player orientation is onValueChange={(value) => {
locked to the app orientation. updateSettings({ openInVLC: value, forceDirectPlay: value });
</Text> }}
</View> />
<Switch </View>
value={settings?.autoRotate}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</View>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Start videos in fullscreen</Text>
<Text className="text-xs opacity-50">
Clicking a video will start it in fullscreen mode, instead of
inline.
</Text>
</View>
<Switch
value={settings?.openFullScreenVideoPlayerByDefault}
onValueChange={(value) =>
updateSettings({ openFullScreenVideoPlayerByDefault: value })
}
/>
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Use external player (VLC)</Text>
<Text className="text-xs opacity-50 shrink">
Open all videos in VLC instead of the default player. This
requries VLC to be installed on the phone.
</Text>
</View>
<Switch
value={settings?.openInVLC}
onValueChange={(value) => {
updateSettings({ openInVLC: value, forceDirectPlay: value });
}}
/>
</View>
<View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col"> <View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4"> <Text className="font-semibold">Use popular lists plugin</Text>
<View className="flex flex-col"> <Text className="text-xs opacity-50">Made by: lostb1t</Text>
<Text className="font-semibold">Use popular lists plugin</Text> <TouchableOpacity
<Text className="text-xs opacity-50">Made by: lostb1t</Text> onPress={() => {
<TouchableOpacity Linking.openURL(
onPress={() => { "https://github.com/lostb1t/jellyfin-plugin-media-lists"
Linking.openURL( );
"https://github.com/lostb1t/jellyfin-plugin-media-lists" }}
); >
}} <Text className="text-xs text-purple-600">More info</Text>
> </TouchableOpacity>
<Text className="text-xs text-purple-600">More info</Text> </View>
</TouchableOpacity> <Switch
</View> value={settings?.usePopularPlugin}
<Switch onValueChange={(value) =>
value={settings?.usePopularPlugin} updateSettings({ usePopularPlugin: value })
onValueChange={(value) => }
updateSettings({ usePopularPlugin: value }) />
} </View>
/> {settings?.usePopularPlugin && (
</View> <View className="flex flex-col py-2 bg-neutral-900">
{settings?.usePopularPlugin && ( {mediaListCollections?.map((mlc) => (
<View className="flex flex-col py-2 bg-neutral-900"> <View
{mediaListCollections?.map((mlc) => ( key={mlc.Id}
<View className="flex flex-row items-center justify-between bg-neutral-900 px-4 py-2"
key={mlc.Id} >
className="flex flex-row items-center justify-between bg-neutral-900 px-4 py-2" <View className="flex flex-col">
> <Text className="font-semibold">{mlc.Name}</Text>
<View className="flex flex-col"> </View>
<Text className="font-semibold">{mlc.Name}</Text> <Switch
</View> value={settings?.mediaListCollectionIds?.includes(mlc.Id!)}
<Switch onValueChange={(value) => {
value={settings?.mediaListCollectionIds?.includes( if (!settings.mediaListCollectionIds) {
mlc.Id! updateSettings({
)} mediaListCollectionIds: [mlc.Id!],
onValueChange={(value) => { });
if (!settings.mediaListCollectionIds) { return;
updateSettings({ }
mediaListCollectionIds: [mlc.Id!],
});
return;
}
updateSettings({ updateSettings({
mediaListCollectionIds: mediaListCollectionIds:
settings?.mediaListCollectionIds.includes(mlc.Id!) settings?.mediaListCollectionIds.includes(mlc.Id!)
? settings?.mediaListCollectionIds.filter( ? settings?.mediaListCollectionIds.filter(
(id) => id !== mlc.Id (id) => id !== mlc.Id
) )
: [...settings?.mediaListCollectionIds, mlc.Id!], : [...settings?.mediaListCollectionIds, mlc.Id!],
}); });
}} }}
/> />
</View> </View>
))} ))}
{isLoadingMediaListCollections && ( {isLoadingMediaListCollections && (
<View className="flex flex-row items-center justify-center bg-neutral-900 p-4"> <View className="flex flex-row items-center justify-center bg-neutral-900 p-4">
<Loader /> <Loader />
</View> </View>
)} )}
{mediaListCollections?.length === 0 && ( {mediaListCollections?.length === 0 && (
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4"> <View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<Text className="text-xs opacity-50"> <Text className="text-xs opacity-50">
No collections found. Add some in Jellyfin. No collections found. Add some in Jellyfin.
</Text> </Text>
</View>
)}
</View> </View>
)} )}
</View> </View>
)}
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4"> <View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink"> <View className="flex flex-col shrink">
<Text className="font-semibold">Force direct play</Text> <Text className="font-semibold">Force direct play</Text>
<Text className="text-xs opacity-50 shrink"> <Text className="text-xs opacity-50 shrink">
This will always request direct play. This is good if you want This will always request direct play. This is good if you want to
to try to stream movies you think the device supports. try to stream movies you think the device supports.
</Text> </Text>
</View> </View>
<Switch <Switch
value={settings?.forceDirectPlay} value={settings?.forceDirectPlay}
onValueChange={(value) => onValueChange={(value) => updateSettings({ forceDirectPlay: value })}
updateSettings({ forceDirectPlay: value }) />
} </View>
/>
</View>
<View <View
className={` className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4 flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
${settings?.forceDirectPlay ? "opacity-50 select-none" : ""} ${settings?.forceDirectPlay ? "opacity-50 select-none" : ""}
`} `}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Device profile</Text>
<Text className="text-xs opacity-50">
A profile used for deciding what audio and video codecs the device
supports.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings?.deviceProfile}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
> >
<View className="flex flex-col shrink"> <DropdownMenu.Label>Profiles</DropdownMenu.Label>
<Text className="font-semibold">Device profile</Text> <DropdownMenu.Item
<Text className="text-xs opacity-50"> key="1"
A profile used for deciding what audio and video codecs the onSelect={() => {
device supports. updateSettings({ deviceProfile: "Expo" });
</Text> }}
</View> >
<DropdownMenu.Root> <DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
<DropdownMenu.Trigger> </DropdownMenu.Item>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"> <DropdownMenu.Item
<Text>{settings?.deviceProfile}</Text> key="2"
</TouchableOpacity> onSelect={() => {
</DropdownMenu.Trigger> updateSettings({ deviceProfile: "Native" });
<DropdownMenu.Content }}
loop={true} >
side="bottom" <DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
align="start" </DropdownMenu.Item>
alignOffset={0} <DropdownMenu.Item
avoidCollisions={true} key="3"
collisionPadding={8} onSelect={() => {
sideOffset={8} updateSettings({ deviceProfile: "Old" });
> }}
<DropdownMenu.Label>Profiles</DropdownMenu.Label> >
<DropdownMenu.Item <DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
key="1" </DropdownMenu.Item>
onSelect={() => { </DropdownMenu.Content>
updateSettings({ deviceProfile: "Expo" }); </DropdownMenu.Root>
}} </View>
> <View className="flex flex-col">
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle> <View
</DropdownMenu.Item> className={`
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ deviceProfile: "Native" });
}}
>
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({ deviceProfile: "Old" });
}}
>
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-col">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4 flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`} `}
> >
<View className="flex flex-col shrink"> <View className="flex flex-col shrink">
<Text className="font-semibold">Search engine</Text> <Text className="font-semibold">Search engine</Text>
<Text className="text-xs opacity-50"> <Text className="text-xs opacity-50">
Choose the search engine you want to use. Choose the search engine you want to use.
</Text> </Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings?.searchEngine}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ searchEngine: "Marlin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
{settings?.searchEngine === "Marlin" && (
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
<>
<View className="flex flex-row items-center space-x-2">
<View className="grow">
<Input
placeholder="Marlin Server URL..."
defaultValue={settings.marlinServerUrl}
value={marlinUrl}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setMarlinUrl(text)}
/>
</View>
<Button
color="purple"
className="shrink w-16 h-12"
onPress={() => {
updateSettings({ marlinServerUrl: marlinUrl });
}}
>
Save
</Button>
</View>
<Text className="text-neutral-500 mt-2">
{settings?.marlinServerUrl}
</Text>
</>
</View>
)}
</View> </View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings?.searchEngine}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ searchEngine: "Marlin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View> </View>
{settings?.searchEngine === "Marlin" && (
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
<>
<View className="flex flex-row items-center space-x-2">
<View className="grow">
<Input
placeholder="Marlin Server URL..."
defaultValue={settings.marlinServerUrl}
value={marlinUrl}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setMarlinUrl(text)}
/>
</View>
<Button
color="purple"
className="shrink w-16 h-12"
onPress={() => {
updateSettings({ marlinServerUrl: marlinUrl });
}}
>
Save
</Button>
</View>
<Text className="text-neutral-500 mt-2">
{settings?.marlinServerUrl}
</Text>
</>
</View>
)}
</View> </View>
</View> </View>
); );

View File

@@ -1,24 +0,0 @@
import { Stack } from "expo-router";
import { Chromecast } from "../Chromecast";
import { HeaderBackButton } from "../common/HeaderBackButton";
const commonScreenOptions = {
title: "",
headerShown: true,
headerTransparent: true,
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
};
const routes = [
"actors/[actorId]",
"albums/[albumId]",
"artists/index",
"artists/[artistId]",
"collections/[collectionId]",
"items/page",
"series/[id]",
];
export const nestedTabPageScreenOptions: { [key: string]: any } =
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));

View File

@@ -21,13 +21,13 @@
} }
}, },
"production": { "production": {
"channel": "0.12.0", "channel": "0.8.2",
"android": { "android": {
"image": "latest" "image": "latest"
} }
}, },
"production-apk": { "production-apk": {
"channel": "0.12.0", "channel": "0.8.2",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"

View File

@@ -1,46 +0,0 @@
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useAtom } from "jotai";
import { useEffect } from "react";
import { getColors } from "react-native-image-colors";
export const useImageColors = (
uri: string | undefined | null,
disabled = false
) => {
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
useEffect(() => {
if (disabled) return;
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, disabled]);
};

View File

@@ -1,19 +0,0 @@
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]);
}

View File

@@ -7,6 +7,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { runningProcesses } from "@/utils/atoms/downloads"; import { runningProcesses } from "@/utils/atoms/downloads";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Platform } from "react-native";
/** /**
* Custom hook for remuxing HLS to MP4 using FFmpeg. * Custom hook for remuxing HLS to MP4 using FFmpeg.
@@ -30,6 +31,16 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
async (url: string) => { async (url: string) => {
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`; const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
// let command: string | null = null;
// if (Platform.OS === "android") {
// command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c:v h264_mediacodec -c:a copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
// } else if (Platform.OS === "ios") {
// command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c:v h264_videotoolbox -c:a copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
// } else {
// throw new Error("Unsupported platform");
// }
writeToLog( writeToLog(
"INFO", "INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}` `useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`

View File

@@ -26,34 +26,32 @@
"@react-native-menu/menu": "^1.1.2", "@react-native-menu/menu": "^1.1.2",
"@react-navigation/native": "^6.0.2", "@react-navigation/native": "^6.0.2",
"@shopify/flash-list": "1.6.4", "@shopify/flash-list": "1.6.4",
"@tanstack/react-query": "^5.54.1", "@tanstack/react-query": "^5.51.16",
"@types/lodash": "^4.17.7", "@types/lodash": "^4.17.7",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"axios": "^1.7.7", "axios": "^1.7.3",
"expo": "~51.0.32", "expo": "~51.0.28",
"expo-blur": "~13.0.2", "expo-blur": "~13.0.2",
"expo-build-properties": "~0.12.5", "expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2", "expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.26", "expo-dev-client": "~4.0.23",
"expo-device": "~6.0.2", "expo-device": "~6.0.2",
"expo-font": "~12.0.10", "expo-font": "~12.0.9",
"expo-haptics": "~13.0.1", "expo-haptics": "~13.0.1",
"expo-image": "~1.12.15", "expo-image": "~1.12.14",
"expo-keep-awake": "~13.0.2", "expo-keep-awake": "~13.0.2",
"expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1", "expo-linking": "~6.3.1",
"expo-navigation-bar": "~3.0.7", "expo-navigation-bar": "~3.0.7",
"expo-network": "~6.0.1",
"expo-router": "~3.5.23", "expo-router": "~3.5.23",
"expo-screen-orientation": "~7.0.5", "expo-screen-orientation": "~7.0.5",
"expo-sensors": "~13.0.9", "expo-sensors": "~13.0.9",
"expo-splash-screen": "~0.27.5", "expo-splash-screen": "~0.27.5",
"expo-status-bar": "~1.12.1", "expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7", "expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.24", "expo-updates": "~0.25.22",
"expo-web-browser": "~13.0.3", "expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2", "ffmpeg-kit-react-native": "^6.0.2",
"jotai": "^2.9.3", "jotai": "^2.9.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"nativewind": "^2.0.11", "nativewind": "^2.0.11",
"react": "18.2.0", "react": "18.2.0",
@@ -64,7 +62,6 @@
"react-native-gesture-handler": "~2.16.1", "react-native-gesture-handler": "~2.16.1",
"react-native-get-random-values": "^1.11.0", "react-native-get-random-values": "^1.11.0",
"react-native-google-cast": "^4.8.2", "react-native-google-cast": "^4.8.2",
"react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^2.5.1", "react-native-ios-context-menu": "^2.5.1",
"react-native-ios-utilities": "^4.4.5", "react-native-ios-utilities": "^4.4.5",
"react-native-reanimated": "~3.10.1", "react-native-reanimated": "~3.10.1",
@@ -74,7 +71,7 @@
"react-native-svg": "15.2.0", "react-native-svg": "15.2.0",
"react-native-url-polyfill": "^2.0.0", "react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2", "react-native-uuid": "^2.0.2",
"react-native-video": "^6.5.0", "react-native-video": "^6.4.3",
"react-native-web": "~0.19.10", "react-native-web": "~0.19.10",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"use-debounce": "^10.0.3", "use-debounce": "^10.0.3",

View File

@@ -1,42 +0,0 @@
const { withAndroidManifest } = require("@expo/config-plugins");
function addAttributesToMainActivity(androidManifest, attributes) {
const { manifest } = androidManifest;
if (!Array.isArray(manifest["application"])) {
console.warn("withAndroidMainActivityAttributes: No application array in manifest?");
return androidManifest;
}
const application = manifest["application"].find(
(item) => item.$["android:name"] === ".MainApplication"
);
if (!application) {
console.warn("withAndroidMainActivityAttributes: No .MainApplication?");
return androidManifest;
}
if (!Array.isArray(application["activity"])) {
console.warn("withAndroidMainActivityAttributes: No activity array in .MainApplication?");
return androidManifest;
}
const activity = application["activity"].find(
(item) => item.$["android:name"] === ".MainActivity"
);
if (!activity) {
console.warn("withAndroidMainActivityAttributes: No .MainActivity?");
return androidManifest;
}
activity.$ = { ...activity.$, ...attributes };
return androidManifest;
}
module.exports = function withAndroidMainActivityAttributes(config, attributes) {
return withAndroidManifest(config, (config) => {
config.modResults = addAttributesToMainActivity(config.modResults, attributes);
return config;
});
};

View File

@@ -1,20 +0,0 @@
const { withAppDelegate } = require("@expo/config-plugins");
const withExpandedController = (config) => {
return withAppDelegate(config, async (config) => {
const contents = config.modResults.contents;
// Looking for the initialProps string inside didFinishLaunchingWithOptions,
// and injecting expanded controller config.
// Should be updated once there is an expo config option - see https://github.com/react-native-google-cast/react-native-google-cast/discussions/537
const injectionIndex = contents.indexOf("self.initialProps = @{};");
config.modResults.contents =
contents.substring(0, injectionIndex) +
`\n [GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true; \n` +
contents.substring(injectionIndex);
return config;
});
};
module.exports = withExpandedController;

View File

@@ -1,19 +1,20 @@
import { useInterval } from "@/hooks/useInterval"; import {
currentlyPlayingItemAtom,
playingAtom,
showCurrentlyPlayingBarAtom,
} from "@/utils/atoms/playState";
import { Api, Jellyfin } from "@jellyfin/sdk"; import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models"; 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 AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import axios, { AxiosError } from "axios"; import axios from "axios";
import { router, useSegments } from "expo-router"; import { router, useSegments } from "expo-router";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import React, { import React, {
createContext, createContext,
ReactNode, ReactNode,
useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo,
useState, useState,
} from "react"; } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
@@ -33,7 +34,6 @@ interface JellyfinContextValue {
removeServer: () => void; removeServer: () => void;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>;
} }
const JellyfinContext = createContext<JellyfinContextValue | undefined>( const JellyfinContext = createContext<JellyfinContextValue | undefined>(
@@ -56,6 +56,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}) => { }) => {
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined); const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
const [deviceId, setDeviceId] = useState<string | undefined>(undefined); const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
const [isConnected, setIsConnected] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -63,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin( setJellyfin(
() => () =>
new Jellyfin({ new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.12.0" }, clientInfo: { name: "Streamyfin", version: "0.8.2" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
}) })
); );
@@ -73,101 +74,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [api, setApi] = useAtom(apiAtom); const [api, setApi] = useAtom(apiAtom);
const [user, setUser] = useAtom(userAtom); const [user, setUser] = useAtom(userAtom);
const [isPolling, setIsPolling] = useState<boolean>(false);
const [secret, setSecret] = useState<string | null>(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.12.0"`,
};
}, [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<Server[]> => { const discoverServers = async (url: string): Promise<Server[]> => {
const servers = await jellyfin?.discovery.getRecommendedServerCandidates( const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
@@ -221,6 +127,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
} }
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
console.log("Axios error", error.response?.status);
switch (error.response?.status) { switch (error.response?.status) {
case 401: case 401:
throw new Error("Invalid username or password"); throw new Error("Invalid username or password");
@@ -297,7 +204,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
login: (username, password) => login: (username, password) =>
loginMutation.mutateAsync({ username, password }), loginMutation.mutateAsync({ username, password }),
logout: () => logoutMutation.mutateAsync(), logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect,
}; };
useProtectedRoute(user, isLoading || isFetching); useProtectedRoute(user, isLoading || isFetching);
@@ -327,7 +233,7 @@ function useProtectedRoute(user: UserDto | null, loading = false) {
if (!user?.Id && inAuthGroup) { if (!user?.Id && inAuthGroup) {
router.replace("/login"); router.replace("/login");
} else if (user?.Id && !inAuthGroup) { } else if (user?.Id && !inAuthGroup) {
router.replace("/(auth)/(tabs)/(home)/"); router.replace("/home");
} }
}, [user, segments, loading]); }, [user, segments, loading]);
} }

View File

@@ -10,29 +10,19 @@ import React, {
} from "react"; } from "react";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDeviceId } from "@/utils/device";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress"; import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped"; import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { postCapabilities } from "@/utils/jellyfin/session/capabilities";
import { import {
BaseItemDto, BaseItemDto,
PlaybackInfoResponse, PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi, getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import * as Linking from "expo-linking";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { debounce, isBuffer } from "lodash";
import { Alert } from "react-native";
import { OnProgressData, type VideoRef } from "react-native-video"; import { OnProgressData, type VideoRef } from "react-native-video";
import { apiAtom, userAtom } from "./JellyfinProvider"; import { apiAtom, userAtom } from "./JellyfinProvider";
import { import { getDeviceId } from "@/utils/device";
GroupData, import * as Linking from "expo-linking";
GroupJoinedData, import { Platform } from "react-native";
PlayQueueData,
StateUpdateData,
} from "@/types/syncplay";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
type CurrentlyPlayingState = { type CurrentlyPlayingState = {
url: string; url: string;
@@ -43,26 +33,20 @@ interface PlaybackContextType {
sessionData: PlaybackInfoResponse | null | undefined; sessionData: PlaybackInfoResponse | null | undefined;
currentlyPlaying: CurrentlyPlayingState | null; currentlyPlaying: CurrentlyPlayingState | null;
videoRef: React.MutableRefObject<VideoRef | null>; videoRef: React.MutableRefObject<VideoRef | null>;
onBuffer: (isBuffering: boolean) => void;
onReady: () => void;
isPlaying: boolean; isPlaying: boolean;
isFullscreen: boolean; isFullscreen: boolean;
progressTicks: number | null; progressTicks: number | null;
playVideo: (triggerRef?: boolean) => void; playVideo: () => void;
pauseVideo: (triggerRef?: boolean) => void; pauseVideo: () => void;
stopPlayback: () => void; stopPlayback: () => void;
presentFullscreenPlayer: () => void; presentFullscreenPlayer: () => void;
dismissFullscreenPlayer: () => void; dismissFullscreenPlayer: () => void;
setIsFullscreen: (isFullscreen: boolean) => void; setIsFullscreen: (isFullscreen: boolean) => void;
setIsPlaying: (isPlaying: boolean) => void; setIsPlaying: (isPlaying: boolean) => void;
onProgress: (data: OnProgressData) => void; onProgress: (data: OnProgressData) => void;
setVolume: (volume: number) => void;
setCurrentlyPlayingState: ( setCurrentlyPlayingState: (
currentlyPlaying: CurrentlyPlayingState | null currentlyPlaying: CurrentlyPlayingState | null
) => void; ) => void;
startDownloadedFilePlayback: (
currentlyPlaying: CurrentlyPlayingState | null
) => void;
} }
const PlaybackContext = createContext<PlaybackContextType | null>(null); const PlaybackContext = createContext<PlaybackContextType | null>(null);
@@ -77,14 +61,9 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
const [settings] = useSettings(); const [settings] = useSettings();
const previousVolume = useRef<number | null>(null); const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isPlaying, _setIsPlaying] = useState<boolean>(false);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false); const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [progressTicks, setProgressTicks] = useState<number | null>(0); const [progressTicks, setProgressTicks] = useState<number | null>(0);
const [volume, _setVolume] = useState<number | null>(null);
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
const [syncplayGroup, setSyncplayGroup] = useState<GroupData | null>(null);
const [currentlyPlaying, setCurrentlyPlaying] = const [currentlyPlaying, setCurrentlyPlaying] =
useState<CurrentlyPlayingState | null>(null); useState<CurrentlyPlayingState | null>(null);
@@ -92,233 +71,101 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
const [ws, setWs] = useState<WebSocket | null>(null); const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const setVolume = useCallback( const { data: sessionData } = useQuery({
(newVolume: number) => { queryKey: ["sessionData", currentlyPlaying?.item.Id, user?.Id, api],
previousVolume.current = volume; queryFn: async () => {
_setVolume(newVolume); if (!currentlyPlaying?.item.Id) return null;
videoRef.current?.setVolume(newVolume); const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: currentlyPlaying?.item.Id,
userId: user?.Id,
});
return playbackData.data;
}, },
[_setVolume] enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
); });
const { data: deviceId } = useQuery({ const { data: deviceId } = useQuery({
queryKey: ["deviceId", api], queryKey: ["deviceId", api],
queryFn: getDeviceId, queryFn: getDeviceId,
}); });
const startDownloadedFilePlayback = useCallback( const setCurrentlyPlayingState = useCallback(
async (state: CurrentlyPlayingState | null) => { (state: CurrentlyPlayingState | null) => {
if (!state) { const vlcLink = "vlc://" + state?.url;
setCurrentlyPlaying(null); console.log(vlcLink, settings?.openInVLC, Platform.OS === "ios");
setIsPlaying(false); if (vlcLink && settings?.openInVLC) {
Linking.openURL("vlc://" + state?.url || "");
return; return;
} }
setCurrentlyPlaying(state); if (state) {
setIsPlaying(true); setCurrentlyPlaying(state);
if (settings?.openFullScreenVideoPlayerByDefault) { setIsPlaying(true);
setTimeout(() => {
if (settings?.openFullScreenVideoPlayerByDefault)
presentFullscreenPlayer(); presentFullscreenPlayer();
}, 300); } else {
setCurrentlyPlaying(null);
setIsFullscreen(false);
setIsPlaying(false);
} }
}, },
[settings?.openFullScreenVideoPlayerByDefault] [settings]
); );
const setCurrentlyPlayingState = useCallback( // Define control methods
async (state: CurrentlyPlayingState | null, paused = false) => { const playVideo = useCallback(() => {
try { videoRef.current?.resume();
if (state?.item.Id && user?.Id) { setIsPlaying(true);
const vlcLink = "vlc://" + state?.url; reportPlaybackProgress({
if (vlcLink && settings?.openInVLC) { api,
Linking.openURL("vlc://" + state?.url || ""); itemId: currentlyPlaying?.item.Id,
return; positionTicks: progressTicks ? progressTicks : 0,
} sessionId: sessionData?.PlaySessionId,
IsPaused: true,
});
}, [
api,
currentlyPlaying?.item.Id,
sessionData?.PlaySessionId,
progressTicks,
]);
const res = await getMediaInfoApi(api!).getPlaybackInfo({ const pauseVideo = useCallback(() => {
itemId: state.item.Id, videoRef.current?.pause();
userId: user.Id, setIsPlaying(false);
}); reportPlaybackProgress({
api,
await postCapabilities({ itemId: currentlyPlaying?.item.Id,
api, positionTicks: progressTicks ? progressTicks : 0,
itemId: state.item.Id, sessionId: sessionData?.PlaySessionId,
sessionId: res.data.PlaySessionId, IsPaused: false,
}); });
}, [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]);
setSession(res.data);
setCurrentlyPlaying(state);
if (paused === true) {
pauseVideo();
} else {
playVideo();
}
if (settings?.openFullScreenVideoPlayerByDefault) {
setTimeout(() => {
presentFullscreenPlayer();
}, 300);
}
} else {
setCurrentlyPlaying(null);
setIsFullscreen(false);
setIsPlaying(false);
}
} catch (e) {
console.error(e);
Alert.alert(
"Something went wrong",
"The item could not be played. Maybe there is no internet connection?",
[
{
style: "destructive",
text: "Try force play",
onPress: () => {
setCurrentlyPlaying(state);
setIsPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault) {
setTimeout(() => {
presentFullscreenPlayer();
}, 300);
}
},
},
{
text: "Ok",
style: "default",
},
]
);
}
},
[settings, user, api]
);
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(
(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 () => { const stopPlayback = useCallback(async () => {
await reportPlaybackStopped({ await reportPlaybackStopped({
api, api,
itemId: currentlyPlaying?.item?.Id, itemId: currentlyPlaying?.item?.Id,
sessionId: session?.PlaySessionId, sessionId: sessionData?.PlaySessionId,
positionTicks: progressTicks ? progressTicks : 0, positionTicks: progressTicks ? progressTicks : 0,
}); });
setCurrentlyPlayingState(null); setCurrentlyPlayingState(null);
}, [currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks, api]); }, [currentlyPlaying, sessionData, progressTicks]);
const setIsPlaying = useCallback( const onProgress = useCallback(
debounce((value: boolean) => {
_setIsPlaying(value);
}, 500),
[]
);
const _onProgress = useCallback(
({ currentTime }: OnProgressData) => { ({ currentTime }: OnProgressData) => {
if (
!session?.PlaySessionId ||
!currentlyPlaying?.item.Id ||
currentTime === 0
)
return;
const ticks = currentTime * 10000000; const ticks = currentTime * 10000000;
setProgressTicks(ticks); setProgressTicks(ticks);
reportPlaybackProgress({ reportPlaybackProgress({
api, api,
itemId: currentlyPlaying?.item.Id, itemId: currentlyPlaying?.item.Id,
positionTicks: ticks, positionTicks: ticks,
sessionId: session?.PlaySessionId, sessionId: sessionData?.PlaySessionId,
IsPaused: !isPlaying, IsPaused: !isPlaying,
}); });
}, },
[session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api] [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying]
);
const onBuffer = useCallback(
(isBuffering: boolean) => {
console.log("Buffering...", "Playing:", isPlaying);
if (
isBuffering &&
syncplayGroup?.GroupId &&
isPlaying === false &&
currentlyPlaying?.item.PlaylistItemId
) {
console.log("Sending syncplay buffering...");
getSyncPlayApi(api!).syncPlayBuffering({
bufferRequestDto: {
IsPlaying: isPlaying,
When: new Date().toISOString(),
PositionTicks: progressTicks ? progressTicks : 0,
PlaylistItemId: currentlyPlaying?.item.PlaylistItemId,
},
});
}
},
[
isPlaying,
syncplayGroup?.GroupId,
currentlyPlaying?.item.PlaylistItemId,
api,
]
);
const onReady = useCallback(() => {
if (syncplayGroup?.GroupId && currentlyPlaying?.item.PlaylistItemId) {
getSyncPlayApi(api!).syncPlayReady({
readyRequestDto: {
When: new Date().toISOString(),
PlaylistItemId: currentlyPlaying?.item.PlaylistItemId,
IsPlaying: isPlaying,
PositionTicks: progressTicks ? progressTicks : 0,
},
});
}
}, [
syncplayGroup?.GroupId,
currentlyPlaying?.item.PlaylistItemId,
progressTicks,
isPlaying,
api,
]);
const onProgress = useCallback(
debounce((e: OnProgressData) => {
_onProgress(e);
}, 1000),
[_onProgress]
); );
const presentFullscreenPlayer = useCallback(() => { const presentFullscreenPlayer = useCallback(() => {
@@ -331,222 +178,84 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
setIsFullscreen(false); setIsFullscreen(false);
}, []); }, []);
const seek = useCallback((ticks: number) => {
const time = ticks / 10000000;
videoRef.current?.seek(time);
}, []);
useEffect(() => { useEffect(() => {
if (!deviceId || !api?.accessToken || !user?.Id) { if (!deviceId || !api?.accessToken) return;
console.info("[WS] Waiting for deviceId, accessToken and userId");
return;
}
const protocol = api?.basePath.includes("https") ? "wss" : "ws"; const url = `wss://${api?.basePath
const url = `${protocol}://${api?.basePath
.replace("https://", "") .replace("https://", "")
.replace("http://", "")}/socket?api_key=${ .replace("http://", "")}/socket?api_key=${
api?.accessToken api?.accessToken
}&deviceId=${deviceId}`; }&deviceId=${deviceId}`;
let ws: WebSocket | null = null; console.log("WS", url);
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null; let keepAliveInterval: NodeJS.Timeout | null = null;
const connect = () => { newWebSocket.onopen = () => {
ws = new WebSocket(url); setIsConnected(true);
// Start sending "KeepAlive" message every 30 seconds
ws.onopen = () => { keepAliveInterval = setInterval(() => {
setIsConnected(true); if (newWebSocket.readyState === WebSocket.OPEN) {
keepAliveInterval = setInterval(() => { newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
if (ws && ws.readyState === WebSocket.OPEN) { console.log("KeepAlive message sent");
console.log("⬆︎ KeepAlive...");
ws.send(JSON.stringify({ MessageType: "KeepAlive" }));
}
}, 30000);
};
ws.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
ws.onclose = () => {
setIsConnected(false);
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
} }
setTimeout(connect, 5000); // Attempt to reconnect after 5 seconds }, 30000);
};
setWs(ws);
}; };
connect(); newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
return () => { newWebSocket.onclose = (e) => {
if (ws) { console.log("WebSocket connection closed:", e.reason);
ws.close();
}
if (keepAliveInterval) { if (keepAliveInterval) {
clearInterval(keepAliveInterval); clearInterval(keepAliveInterval);
} }
}; };
}, [api?.accessToken, deviceId, user]);
setWs(newWebSocket);
return () => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
newWebSocket.close();
};
}, [api, deviceId, user]);
useEffect(() => { useEffect(() => {
if (!ws || !api) return; if (!ws) return;
ws.onmessage = (e) => { ws.onmessage = (e) => {
const json = JSON.parse(e.data); const json = JSON.parse(e.data);
const command = json?.Data?.Command; const command = json?.Data?.Command;
if (json.MessageType === "KeepAlive") { // On PlayPause
console.log("⬇︎ KeepAlive...");
} else if (json.MessageType === "ForceKeepAlive") {
console.log("⬇︎ ForceKeepAlive...");
} else if (json.MessageType === "SyncPlayCommand") {
console.log("SyncPlayCommand ~", command, json.Data);
switch (command) {
case "Stop":
console.log("STOP");
stopPlayback();
break;
case "Pause":
console.log("PAUSE");
pauseVideo();
break;
case "Play":
case "Unpause":
console.log("PLAY");
playVideo();
break;
case "Seek":
console.log("SEEK", json.Data.PositionTicks);
seek(json.Data.PositionTicks);
break;
}
} else if (json.MessageType === "SyncPlayGroupUpdate") {
const type = json.Data.Type;
if (type === "StateUpdate") {
const data = json.Data.Data as StateUpdateData;
console.log("StateUpdate ~", data);
} else if (type === "GroupJoined") {
const data = json.Data.Data as GroupData;
setSyncplayGroup(data);
console.log("GroupJoined ~", data);
} else if (type === "GroupLeft") {
console.log("GroupLeft");
setSyncplayGroup(null);
} else if (type === "PlayQueue") {
const data = json.Data.Data as PlayQueueData;
console.log("PlayQueue ~", {
IsPlaying: data.IsPlaying,
Reason: data.Reason,
});
if (data.Reason === "SetCurrentItem") {
console.log("SetCurrentItem ~ ", json);
return;
}
if (data.Reason === "NewPlaylist") {
const itemId = data.Playlist?.[data.PlayingItemIndex].ItemId;
if (!itemId) {
console.error("No itemId found in PlayQueue");
return;
}
// Set playback item
getUserItemData({
api,
userId: user?.Id,
itemId,
}).then(async (item) => {
if (!item) {
Alert.alert("Error", "Could not find item for syncplay");
return;
}
const url = await getStreamUrl({
api,
item,
startTimeTicks: data.StartPositionTicks,
userId: user?.Id,
mediaSourceId: item?.MediaSources?.[0].Id!,
});
if (!url) {
Alert.alert("Error", "Could not find stream url for syncplay");
return;
}
await setCurrentlyPlayingState(
{
item,
url,
},
!data.IsPlaying
);
await getSyncPlayApi(api).syncPlayReady({
readyRequestDto: {
IsPlaying: data.IsPlaying,
PositionTicks: data.StartPositionTicks,
PlaylistItemId: data.Playlist[0].PlaylistItemId,
When: new Date().toISOString(),
},
});
});
}
} else {
console.log("[WS] ~ ", json);
}
return;
} else {
console.log("[WS] ~ ", json);
}
if (command === "PlayPause") { if (command === "PlayPause") {
// On PlayPause
console.log("Command ~ PlayPause"); console.log("Command ~ PlayPause");
if (isPlaying) pauseVideo(); if (isPlaying) pauseVideo();
else playVideo(); else playVideo();
} else if (command === "Stop") { } else if (command === "Stop") {
console.log("Command ~ Stop"); console.log("Command ~ Stop");
stopPlayback(); 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, setVolume, api, seek]); }, [ws, stopPlayback, playVideo, pauseVideo]);
return ( return (
<PlaybackContext.Provider <PlaybackContext.Provider
value={{ value={{
onProgress, onProgress,
onReady,
progressTicks, progressTicks,
setVolume,
setIsPlaying, setIsPlaying,
setIsFullscreen, setIsFullscreen,
onBuffer,
isFullscreen, isFullscreen,
isPlaying, isPlaying,
currentlyPlaying, currentlyPlaying,
sessionData: session, sessionData,
videoRef, videoRef,
playVideo, playVideo,
setCurrentlyPlayingState, setCurrentlyPlayingState,
@@ -554,7 +263,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
stopPlayback, stopPlayback,
presentFullscreenPlayer, presentFullscreenPlayer,
dismissFullscreenPlayer, dismissFullscreenPlayer,
startDownloadedFilePlayback,
}} }}
> >
{children} {children}

View File

@@ -1,47 +0,0 @@
export type PlaylistItem = {
ItemId: string;
PlaylistItemId: string;
};
export type PlayQueueData = {
IsPlaying: boolean;
LastUpdate: string;
PlayingItemIndex: number;
Playlist: PlaylistItem[];
Reason: "NewPlaylist" | "SetCurrentItem"; // or use string if more values are expected
RepeatMode: "RepeatNone"; // or use string if more values are expected
ShuffleMode: "Sorted"; // or use string if more values are expected
StartPositionTicks: number;
};
export type GroupData = {
GroupId: string;
GroupName: string;
LastUpdatedAt: string;
Participants: Participant[];
State: string; // You can use an enum or union type if there are known possible states
};
export type SyncPlayCommandData = {
Command: string;
EmittedAt: string;
GroupId: string;
PlaylistItemId: string;
PositionTicks: number;
When: string;
};
export type StateUpdateData = {
State: "Waiting" | "Playing" | "Paused";
Reason: "Pause" | "Unpause";
};
export type GroupJoinedData = {
GroupId: string;
GroupName: string;
LastUpdatedAt: string;
Participants: string[];
State: "Idle";
};
export type Participant = string[];

View File

@@ -1,133 +1,50 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import {
import { atom } from "jotai"; ItemFilter,
import { atomWithStorage, createJSONStorage } from "jotai/utils"; ItemSortBy,
NameGuidPair,
export enum SortByOption { SortOrder,
Default = "Default", } from "@jellyfin/sdk/lib/generated-client/models";
SortName = "SortName", import { atom, useAtom } from "jotai";
CommunityRating = "CommunityRating",
CriticRating = "CriticRating",
DateCreated = "DateCreated",
DatePlayed = "DatePlayed",
PlayCount = "PlayCount",
ProductionYear = "ProductionYear",
Runtime = "Runtime",
OfficialRating = "OfficialRating",
PremiereDate = "PremiereDate",
StartDate = "StartDate",
IsUnplayed = "IsUnplayed",
IsPlayed = "IsPlayed",
AirTime = "AirTime",
Studio = "Studio",
IsFavoriteOrLiked = "IsFavoriteOrLiked",
Random = "Random",
}
export enum SortOrderOption {
Ascending = "Ascending",
Descending = "Descending",
}
export const sortOptions: { export const sortOptions: {
key: SortByOption; key: ItemSortBy;
value: string; value: string;
}[] = [ }[] = [
{ key: SortByOption.Default, value: "Default" }, { key: "SortName", value: "Name" },
{ key: SortByOption.SortName, value: "Name" }, { key: "CommunityRating", value: "Community Rating" },
{ key: SortByOption.CommunityRating, value: "Community Rating" }, { key: "CriticRating", value: "Critics Rating" },
{ key: SortByOption.CriticRating, value: "Critics Rating" }, { key: "DateCreated", value: "Date Added" },
{ key: SortByOption.DateCreated, value: "Date Added" }, // Only works for shows (last episode added) keeping for future ref.
{ key: SortByOption.DatePlayed, value: "Date Played" }, // { key: "DateLastContentAdded", value: "Content Added" },
{ key: SortByOption.PlayCount, value: "Play Count" }, { key: "DatePlayed", value: "Date Played" },
{ key: SortByOption.ProductionYear, value: "Production Year" }, { key: "PlayCount", value: "Play Count" },
{ key: SortByOption.Runtime, value: "Runtime" }, { key: "ProductionYear", value: "Production Year" },
{ key: SortByOption.OfficialRating, value: "Official Rating" }, { key: "Runtime", value: "Runtime" },
{ key: SortByOption.PremiereDate, value: "Premiere Date" }, { key: "OfficialRating", value: "Official Rating" },
{ key: SortByOption.StartDate, value: "Start Date" }, { key: "PremiereDate", value: "Premiere Date" },
{ key: SortByOption.IsUnplayed, value: "Is Unplayed" }, { key: "StartDate", value: "Start Date" },
{ key: SortByOption.IsPlayed, value: "Is Played" }, { key: "IsUnplayed", value: "Is Unplayed" },
{ key: SortByOption.AirTime, value: "Air Time" }, { key: "IsPlayed", value: "Is Played" },
{ key: SortByOption.Studio, value: "Studio" }, // Broken in JF
{ key: SortByOption.IsFavoriteOrLiked, value: "Is Favorite Or Liked" }, // { key: "VideoBitRate", value: "Video Bit Rate" },
{ key: SortByOption.Random, value: "Random" }, { key: "AirTime", value: "Air Time" },
{ key: "Studio", value: "Studio" },
{ key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" },
{ key: "Random", value: "Random" },
]; ];
export const sortOrderOptions: { export const sortOrderOptions: {
key: SortOrderOption; key: SortOrder;
value: string; value: string;
}[] = [ }[] = [
{ key: SortOrderOption.Ascending, value: "Ascending" }, { key: "Ascending", value: "Ascending" },
{ key: SortOrderOption.Descending, value: "Descending" }, { key: "Descending", value: "Descending" },
]; ];
export const genreFilterAtom = atom<string[]>([]); export const genreFilterAtom = atom<string[]>([]);
export const tagsFilterAtom = atom<string[]>([]); export const tagsFilterAtom = atom<string[]>([]);
export const yearFilterAtom = atom<string[]>([]); export const yearFilterAtom = atom<string[]>([]);
export const sortByAtom = atom<SortByOption[]>([SortByOption.Default]); export const sortByAtom = atom<[typeof sortOptions][number]>([sortOptions[0]]);
export const sortOrderAtom = atom<SortOrderOption[]>([ export const sortOrderAtom = atom<[typeof sortOrderOptions][number]>([
SortOrderOption.Ascending, sortOrderOptions[0],
]); ]);
/**
* Sort preferences with persistence
*/
export interface SortPreference {
[libraryId: string]: SortByOption;
}
export interface SortOrderPreference {
[libraryId: string]: SortOrderOption;
}
const defaultSortPreference: SortPreference = {};
const defaultSortOrderPreference: SortOrderPreference = {};
export const sortByPreferenceAtom = atomWithStorage<SortPreference>(
"sortByPreference",
defaultSortPreference,
{
getItem: async (key) => {
const value = await AsyncStorage.getItem(key);
return value ? JSON.parse(value) : null;
},
setItem: async (key, value) => {
await AsyncStorage.setItem(key, JSON.stringify(value));
},
removeItem: async (key) => {
await AsyncStorage.removeItem(key);
},
}
);
export const sortOrderPreferenceAtom = atomWithStorage<SortOrderPreference>(
"sortOrderPreference",
defaultSortOrderPreference,
{
getItem: async (key) => {
const value = await AsyncStorage.getItem(key);
return value ? JSON.parse(value) : null;
},
setItem: async (key, value) => {
await AsyncStorage.setItem(key, JSON.stringify(value));
},
removeItem: async (key) => {
await AsyncStorage.removeItem(key);
},
}
);
// Helper functions to get and set sort preferences
export const getSortByPreference = (
libraryId: string,
preferences: SortPreference
) => {
return preferences?.[libraryId] || null;
};
export const getSortOrderPreference = (
libraryId: string,
preferences: SortOrderPreference
) => {
return preferences?.[libraryId] || null;
};

View File

@@ -1,6 +0,0 @@
import * as ScreenOrientation from "expo-screen-orientation";
import { atom } from "jotai";
export const orientationAtom = atom<number>(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);

View File

@@ -1,73 +0,0 @@
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<ThemeColors>({
primary: "#FFFFFF",
secondary: "#000000",
average: "#888888",
text: "#000000",
});
export const itemThemeColorAtom = atom(
(get) => get(baseThemeColorAtom),
(get, set, update: Partial<ThemeColors>) => {
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);

View File

@@ -24,19 +24,6 @@ export const DownloadOptions: DownloadOption[] = [
}, },
]; ];
export type LibraryOptions = {
display: "row" | "list";
cardStyle: "compact" | "detailed";
imageStyle: "poster" | "cover";
showTitles: boolean;
showStats: boolean;
};
export type DefaultLanguageOption = {
value: string;
label: string;
};
type Settings = { type Settings = {
autoRotate?: boolean; autoRotate?: boolean;
forceLandscapeInVideoPlayer?: boolean; forceLandscapeInVideoPlayer?: boolean;
@@ -49,10 +36,6 @@ type Settings = {
marlinServerUrl?: string; marlinServerUrl?: string;
openInVLC?: boolean; openInVLC?: boolean;
downloadQuality?: DownloadOption; downloadQuality?: DownloadOption;
libraryOptions: LibraryOptions;
defaultSubtitleLanguage: DefaultLanguageOption | null;
defaultAudioLanguage: DefaultLanguageOption | null;
showHomeTitles: boolean;
}; };
/** /**
@@ -76,16 +59,6 @@ const loadSettings = async (): Promise<Settings> => {
marlinServerUrl: "", marlinServerUrl: "",
openInVLC: false, openInVLC: false,
downloadQuality: DownloadOptions[0], downloadQuality: DownloadOptions[0],
libraryOptions: {
display: "list",
cardStyle: "detailed",
imageStyle: "cover",
showTitles: true,
showStats: true,
},
defaultAudioLanguage: null,
defaultSubtitleLanguage: null,
showHomeTitles: true,
}; };
try { try {

View File

@@ -1,87 +0,0 @@
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { ImageSource } from "expo-image";
interface Props {
item: BaseItemDto;
api: Api;
quality?: number;
width?: number;
variant?:
| "Primary"
| "Backdrop"
| "ParentBackdrop"
| "ParentLogo"
| "Logo"
| "AlbumPrimary"
| "SeriesPrimary"
| "Screenshot"
| "Thumb";
}
export const getItemImage = ({
item,
api,
variant = "Primary",
quality = 90,
width = 1000,
}: Props) => {
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}&width=${width}`,
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}&width=${width}`,
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}&width=${width}`,
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}&width=${width}`,
blurhash,
};
break;
default:
tag = item.ImageTags?.["Primary"];
src = {
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}&width=${width}`,
};
break;
}
if (!src?.uri) return null;
return src;
};

View File

@@ -36,10 +36,6 @@ export const getBackdropUrl = ({
params.append("fillWidth", width.toString()); params.append("fillWidth", width.toString());
} }
if (item.Type === "Episode") {
return getPrimaryImageUrl({ api, item, quality, width });
}
if (backdropImageTags) { if (backdropImageTags) {
params.append("tag", backdropImageTags); params.append("tag", backdropImageTags);
return `${api.basePath}/Items/${ return `${api.basePath}/Items/${

Some files were not shown because too many files have changed in this diff Show More