mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-20 15:54:42 +01:00
Compare commits
31 Commits
v0.6.1
...
feat/vlc-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a29e6a3815 | ||
|
|
92b847a447 | ||
|
|
e7fcf806b3 | ||
|
|
eed4df6a8a | ||
|
|
5e081751a4 | ||
|
|
09f953ebba | ||
|
|
4873aaf3df | ||
|
|
9bbab4f46f | ||
|
|
469e8b3f01 | ||
|
|
1c31458dd4 | ||
|
|
4c097c557f | ||
|
|
c23ca905c8 | ||
|
|
ed3170af76 | ||
|
|
e22dd759c7 | ||
|
|
aa44caa161 | ||
|
|
27260faea8 | ||
|
|
ec7e5f869d | ||
|
|
8e1a07e819 | ||
|
|
250c1968f3 | ||
|
|
caeedfbc52 | ||
|
|
66ce6b2cfa | ||
|
|
388480adef | ||
|
|
e911f99b26 | ||
|
|
73ff0aa66a | ||
|
|
29ae6747c4 | ||
|
|
44444e3b37 | ||
|
|
0e3f289d43 | ||
|
|
a66648c67c | ||
|
|
6dc9538483 | ||
|
|
cb7c018cf4 | ||
|
|
a01217b8ac |
29
README.md
29
README.md
@@ -26,22 +26,31 @@ Streamyfin includes some exciting experimental features like media downloading a
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
### Collection rows
|
||||||
|
|
||||||
|
Jellyfin collections can be shown as rows or carousel on the home screen.
|
||||||
|
The following tags can be added to an collection to provide this functionality.
|
||||||
|
|
||||||
|
Avaiable tags:
|
||||||
|
- sf_promoted: Wil make the collection an row 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 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.
|
||||||
|
|
||||||
## Roadmap for V1
|
## Roadmap for V1
|
||||||
|
|
||||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
|
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
|
||||||
|
|
||||||
## Get it now
|
## Get it now
|
||||||
|
|
||||||
<div style="display:flex;">
|
<div style="display: flex; gap: 5px;">
|
||||||
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB">
|
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||||
<img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/>
|
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin">
|
|
||||||
<img height=75 alt="Get the beta on Google Play" src="./assets/en_badge_web_generic.png"/>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
Or download the APKs [here on GitHub](https://github.com/fredrikburmester/streamyfin/releases) for Android.
|
||||||
|
|
||||||
### Beta testing
|
### Beta testing
|
||||||
|
|
||||||
Get the latest updates by using the TestFlight version of the app.
|
Get the latest updates by using the TestFlight version of the app.
|
||||||
@@ -50,8 +59,6 @@ Get the latest updates by using the TestFlight version of the app.
|
|||||||
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
|
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
Or download the APKs here on GitHub for Android.
|
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -106,7 +113,7 @@ Key points of the MPL-2.0:
|
|||||||
|
|
||||||
## 🌐 Connect with Us
|
## 🌐 Connect with Us
|
||||||
|
|
||||||
Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/zyGKHJZvv4)
|
Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/aJvAYeycyY)
|
||||||
|
|
||||||
If you have questions or need support, feel free to reach out:
|
If you have questions or need support, feel free to reach out:
|
||||||
|
|
||||||
|
|||||||
13
app.json
13
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.6.1",
|
"version": "0.6.2",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -96,6 +96,17 @@
|
|||||||
{
|
{
|
||||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"react-native-vlc-media-player",
|
||||||
|
{
|
||||||
|
"ios": {
|
||||||
|
"includeVLCKit": false // should be true if react-native version < 0.61
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"legacyJetifier": false // should be true if react-native version < 0.71
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="library"
|
name="libraries"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
title: "Library",
|
title: "Library",
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import {
|
|||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getFilterApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import {
|
||||||
|
getFilterApi,
|
||||||
|
getItemsApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
|
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -40,7 +44,7 @@ const isCloseToBottom = ({
|
|||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
const { collectionId } = searchParams as { collectionId: string };
|
const { libraryId } = searchParams as { libraryId: string };
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -52,18 +56,18 @@ const page: React.FC = () => {
|
|||||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
const { data: library } = useQuery({
|
||||||
queryKey: ["collection", collectionId],
|
queryKey: ["library", libraryId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getUserLibraryApi(api).getItem({
|
||||||
|
itemId: libraryId,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
ids: [collectionId],
|
|
||||||
});
|
});
|
||||||
const data = response.data.Items?.[0];
|
const data = response.data;
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && !!collectionId,
|
enabled: !!api && !!user?.Id && !!libraryId,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,11 +77,11 @@ const page: React.FC = () => {
|
|||||||
}: {
|
}: {
|
||||||
pageParam: number;
|
pageParam: number;
|
||||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
if (!api || !collection) return null;
|
if (!api || !library) return null;
|
||||||
|
|
||||||
const includeItemTypes: BaseItemKind[] = [];
|
const includeItemTypes: BaseItemKind[] = [];
|
||||||
|
|
||||||
switch (collection?.CollectionType) {
|
switch (library?.CollectionType) {
|
||||||
case "movies":
|
case "movies":
|
||||||
includeItemTypes.push("Movie");
|
includeItemTypes.push("Movie");
|
||||||
break;
|
break;
|
||||||
@@ -96,7 +100,7 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: libraryId,
|
||||||
limit: 66,
|
limit: 66,
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||||
@@ -116,8 +120,8 @@ const page: React.FC = () => {
|
|||||||
[
|
[
|
||||||
api,
|
api,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
collectionId,
|
libraryId,
|
||||||
collection?.CollectionType,
|
library,
|
||||||
selectedGenres,
|
selectedGenres,
|
||||||
selectedYears,
|
selectedYears,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
@@ -129,7 +133,7 @@ const page: React.FC = () => {
|
|||||||
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
|
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"library-items",
|
"library-items",
|
||||||
collection,
|
library,
|
||||||
selectedGenres,
|
selectedGenres,
|
||||||
selectedYears,
|
selectedYears,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
@@ -158,7 +162,7 @@ const page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
enabled: !!api && !!user?.Id && !!collection,
|
enabled: !!api && !!user?.Id && !!library,
|
||||||
});
|
});
|
||||||
|
|
||||||
const type = useMemo(() => {
|
const type = useMemo(() => {
|
||||||
@@ -169,7 +173,7 @@ const page: React.FC = () => {
|
|||||||
return data?.pages.flatMap((p) => p?.Items) || [];
|
return data?.pages.flatMap((p) => p?.Items) || [];
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
if (!collection || !collection.CollectionType) return null;
|
if (!library || !library.CollectionType) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -187,7 +191,7 @@ const page: React.FC = () => {
|
|||||||
<View className="flex flex-row space-x-1 px-3">
|
<View className="flex flex-row space-x-1 px-3">
|
||||||
<ResetFiltersButton />
|
<ResetFiltersButton />
|
||||||
<FilterButton
|
<FilterButton
|
||||||
collectionId={collectionId}
|
collectionId={libraryId}
|
||||||
queryKey="genreFilter"
|
queryKey="genreFilter"
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
@@ -196,7 +200,7 @@ const page: React.FC = () => {
|
|||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
includeItemTypes: type ? [type] : [],
|
includeItemTypes: type ? [type] : [],
|
||||||
parentId: collectionId,
|
parentId: libraryId,
|
||||||
});
|
});
|
||||||
return response.data.Genres || [];
|
return response.data.Genres || [];
|
||||||
}}
|
}}
|
||||||
@@ -209,7 +213,7 @@ const page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
collectionId={collectionId}
|
collectionId={libraryId}
|
||||||
queryKey="tagsFilter"
|
queryKey="tagsFilter"
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
@@ -218,7 +222,7 @@ const page: React.FC = () => {
|
|||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
includeItemTypes: type ? [type] : [],
|
includeItemTypes: type ? [type] : [],
|
||||||
parentId: collectionId,
|
parentId: libraryId,
|
||||||
});
|
});
|
||||||
return response.data.Tags || [];
|
return response.data.Tags || [];
|
||||||
}}
|
}}
|
||||||
@@ -231,7 +235,7 @@ const page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
collectionId={collectionId}
|
collectionId={libraryId}
|
||||||
queryKey="yearFilter"
|
queryKey="yearFilter"
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
@@ -240,7 +244,7 @@ const page: React.FC = () => {
|
|||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
includeItemTypes: type ? [type] : [],
|
includeItemTypes: type ? [type] : [],
|
||||||
parentId: collectionId,
|
parentId: libraryId,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
response.data.Years?.sort((a, b) => b - a).map((y) =>
|
response.data.Years?.sort((a, b) => b - a).map((y) =>
|
||||||
@@ -258,7 +262,7 @@ const page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
icon="sort"
|
icon="sort"
|
||||||
collectionId={collectionId}
|
collectionId={libraryId}
|
||||||
queryKey="sortByFilter"
|
queryKey="sortByFilter"
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
return sortOptions;
|
return sortOptions;
|
||||||
@@ -276,7 +280,7 @@ const page: React.FC = () => {
|
|||||||
<FilterButton
|
<FilterButton
|
||||||
icon="sort"
|
icon="sort"
|
||||||
showSearch={false}
|
showSearch={false}
|
||||||
collectionId={collectionId}
|
collectionId={libraryId}
|
||||||
queryKey="orderByFilter"
|
queryKey="orderByFilter"
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
return sortOrderOptions;
|
return sortOrderOptions;
|
||||||
@@ -305,7 +309,7 @@ const page: React.FC = () => {
|
|||||||
(item, index) =>
|
(item, index) =>
|
||||||
item && (
|
item && (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={`${item.Id}`}
|
key={`${item.Id}-${index}`}
|
||||||
style={{
|
style={{
|
||||||
width: "32%",
|
width: "32%",
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
@@ -16,7 +16,7 @@ export default function IndexLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="collections/[collectionId]"
|
name="[libraryId]"
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
@@ -49,7 +49,7 @@ export default function index() {
|
|||||||
paddingBottom: 150,
|
paddingBottom: 150,
|
||||||
}}
|
}}
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={({ item }) => <CollectionCard collection={item} />}
|
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||||
keyExtractor={(item) => item.Id || ""}
|
keyExtractor={(item) => item.Id || ""}
|
||||||
ItemSeparatorComponent={() => <View className="h-4" />}
|
ItemSeparatorComponent={() => <View className="h-4" />}
|
||||||
estimatedItemSize={200}
|
estimatedItemSize={200}
|
||||||
@@ -58,10 +58,10 @@ export default function index() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
collection: BaseItemDto;
|
library: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CollectionCard: React.FC<Props> = ({ collection }) => {
|
const LibraryItemCard: React.FC<Props> = ({ library }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -70,9 +70,9 @@ const CollectionCard: React.FC<Props> = ({ collection }) => {
|
|||||||
() =>
|
() =>
|
||||||
getPrimaryImageUrl({
|
getPrimaryImageUrl({
|
||||||
api,
|
api,
|
||||||
item: collection,
|
item: library,
|
||||||
}),
|
}),
|
||||||
[collection]
|
[library]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
@@ -80,7 +80,7 @@ const CollectionCard: React.FC<Props> = ({ collection }) => {
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push(`/library/collections/${collection.Id}`);
|
router.push(`/libraries/${library.Id}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex justify-center rounded-xl w-full relative border border-neutral-900 h-20 ">
|
<View className="flex justify-center rounded-xl w-full relative border border-neutral-900 h-20 ">
|
||||||
@@ -96,7 +96,7 @@ const CollectionCard: React.FC<Props> = ({ collection }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Text className="font-bold text-xl text-start px-4">
|
<Text className="font-bold text-xl text-start px-4">
|
||||||
{collection.Name}
|
{library.Name}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -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";
|
||||||
@@ -9,13 +10,32 @@ import AlbumCover from "@/components/posters/AlbumCover";
|
|||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
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 { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router, useNavigation } from "expo-router";
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
Href,
|
||||||
|
router,
|
||||||
|
useLocalSearchParams,
|
||||||
|
useNavigation,
|
||||||
|
usePathname,
|
||||||
|
} from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useLayoutEffect, useMemo, useState } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
@@ -29,6 +49,10 @@ const exampleSearches = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function search() {
|
export default function search() {
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
|
||||||
|
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||||
|
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
|
|
||||||
const [debouncedSearch] = useDebounce(search, 500);
|
const [debouncedSearch] = useDebounce(search, 500);
|
||||||
@@ -36,107 +60,131 @@ export default function search() {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
const searchEngine = useMemo(() => {
|
||||||
|
return settings?.searchEngine || "Jellyfin";
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (q && q.length > 0) setSearch(q);
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
const searchFn = useCallback(
|
||||||
|
async ({
|
||||||
|
types,
|
||||||
|
query,
|
||||||
|
}: {
|
||||||
|
types: BaseItemKind[];
|
||||||
|
query: string;
|
||||||
|
}): Promise<BaseItemDto[]> => {
|
||||||
|
if (!api) return [];
|
||||||
|
|
||||||
|
if (searchEngine === "Jellyfin") {
|
||||||
|
const searchApi = await getSearchApi(api).getSearchHints({
|
||||||
|
searchTerm: query,
|
||||||
|
limit: 10,
|
||||||
|
includeItemTypes: types,
|
||||||
|
});
|
||||||
|
|
||||||
|
return searchApi.data.SearchHints as BaseItemDto[];
|
||||||
|
} else {
|
||||||
|
const url = `${settings?.marlinServerUrl}/search?q=${encodeURIComponent(
|
||||||
|
query
|
||||||
|
)}&includeItemTypes=${types
|
||||||
|
.map((type) => encodeURIComponent(type))
|
||||||
|
.join("&includeItemTypes=")}`;
|
||||||
|
|
||||||
|
const response1 = await axios.get(url);
|
||||||
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
|
if (!ids || !ids.length) return [];
|
||||||
|
|
||||||
|
const response2 = await getItemsApi(api).getItems({
|
||||||
|
ids,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response2.data.Items as BaseItemDto[];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, settings]
|
||||||
|
);
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (Platform.OS === "ios")
|
if (Platform.OS === "ios")
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerSearchBarOptions: {
|
headerSearchBarOptions: {
|
||||||
placeholder: "Search...",
|
placeholder: "Search...",
|
||||||
onChangeText: (e: any) => setSearch(e.nativeEvent.text),
|
onChangeText: (e: any) => {
|
||||||
|
router.setParams({ q: "" });
|
||||||
|
setSearch(e.nativeEvent.text);
|
||||||
|
},
|
||||||
hideWhenScrolling: false,
|
hideWhenScrolling: false,
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
const { data: movies, isLoading: l1 } = useQuery({
|
const { data: movies, isFetching: l1 } = useQuery({
|
||||||
queryKey: ["search-movies", debouncedSearch],
|
queryKey: ["search", "movies", debouncedSearch],
|
||||||
queryFn: async () => {
|
queryFn: () =>
|
||||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
searchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
types: ["Movie"],
|
||||||
searchTerm: debouncedSearch,
|
}),
|
||||||
limit: 10,
|
enabled: debouncedSearch.length > 0,
|
||||||
includeItemTypes: ["Movie"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return searchApi.data.SearchHints;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: series, isLoading: l2 } = useQuery({
|
const { data: series, isFetching: l2 } = useQuery({
|
||||||
queryKey: ["search-series", debouncedSearch],
|
queryKey: ["search", "series", debouncedSearch],
|
||||||
queryFn: async () => {
|
queryFn: () =>
|
||||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
searchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
types: ["Series"],
|
||||||
searchTerm: debouncedSearch,
|
}),
|
||||||
limit: 10,
|
enabled: debouncedSearch.length > 0,
|
||||||
includeItemTypes: ["Series"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return searchApi.data.SearchHints;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: episodes, isLoading: l3 } = useQuery({
|
const { data: episodes, isFetching: l3 } = useQuery({
|
||||||
queryKey: ["search-episodes", debouncedSearch],
|
queryKey: ["search", "episodes", debouncedSearch],
|
||||||
queryFn: async () => {
|
queryFn: () =>
|
||||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
searchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
types: ["Episode"],
|
||||||
searchTerm: debouncedSearch,
|
}),
|
||||||
limit: 10,
|
enabled: debouncedSearch.length > 0,
|
||||||
includeItemTypes: ["Episode"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return searchApi.data.SearchHints;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: artists, isLoading: l4 } = useQuery({
|
const { data: artists, isFetching: l4 } = useQuery({
|
||||||
queryKey: ["search-artists", debouncedSearch],
|
queryKey: ["search", "artists", debouncedSearch],
|
||||||
queryFn: async () => {
|
queryFn: () =>
|
||||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
searchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
types: ["MusicArtist"],
|
||||||
searchTerm: debouncedSearch,
|
}),
|
||||||
limit: 10,
|
enabled: debouncedSearch.length > 0,
|
||||||
includeItemTypes: ["MusicArtist"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return searchApi.data.SearchHints;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: albums, isLoading: l5 } = useQuery({
|
const { data: albums, isFetching: l5 } = useQuery({
|
||||||
queryKey: ["search-albums", debouncedSearch],
|
queryKey: ["search", "albums", debouncedSearch],
|
||||||
queryFn: async () => {
|
queryFn: () =>
|
||||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
searchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
types: ["MusicAlbum"],
|
||||||
searchTerm: debouncedSearch,
|
}),
|
||||||
limit: 10,
|
enabled: debouncedSearch.length > 0,
|
||||||
includeItemTypes: ["MusicAlbum"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return searchApi.data.SearchHints;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: songs, isLoading: l6 } = useQuery({
|
const { data: songs, isFetching: l6 } = useQuery({
|
||||||
queryKey: ["search-songs", debouncedSearch],
|
queryKey: ["search", "songs", debouncedSearch],
|
||||||
queryFn: async () => {
|
queryFn: () =>
|
||||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
searchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
types: ["Audio"],
|
||||||
searchTerm: debouncedSearch,
|
}),
|
||||||
limit: 10,
|
enabled: debouncedSearch.length > 0,
|
||||||
includeItemTypes: ["Audio"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return searchApi.data.SearchHints;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const noResults = useMemo(() => {
|
const noResults = useMemo(() => {
|
||||||
@@ -173,6 +221,13 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{!!q && (
|
||||||
|
<View className="px-4 flex flex-col space-y-2">
|
||||||
|
<Text className="text-neutral-500 ">
|
||||||
|
Results for <Text className="text-purple-600">{q}</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
header="Movies"
|
header="Movies"
|
||||||
ids={movies?.map((m) => m.Id!)}
|
ids={movies?.map((m) => m.Id!)}
|
||||||
@@ -182,7 +237,7 @@ export default function search() {
|
|||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-32"
|
className="flex flex-col w-28"
|
||||||
onPress={() => router.push(`/items/${item.Id}`)}
|
onPress={() => router.push(`/items/${item.Id}`)}
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} key={item.Id} />
|
<MoviePoster item={item} key={item.Id} />
|
||||||
@@ -207,7 +262,7 @@ export default function search() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
onPress={() => router.push(`/series/${item.Id}`)}
|
onPress={() => router.push(`/series/${item.Id}`)}
|
||||||
className="flex flex-col w-32"
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
<SeriesPoster item={item} key={item.Id} />
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
<Text numberOfLines={2} className="mt-2">
|
<Text numberOfLines={2} className="mt-2">
|
||||||
@@ -231,7 +286,7 @@ export default function search() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
onPress={() => router.push(`/items/${item.Id}`)}
|
onPress={() => router.push(`/items/${item.Id}`)}
|
||||||
className="flex flex-col w-48"
|
className="flex flex-col w-44"
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
@@ -250,7 +305,7 @@ export default function search() {
|
|||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-32"
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
<AlbumCover id={item.Id} />
|
<AlbumCover id={item.Id} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
@@ -269,7 +324,7 @@ export default function search() {
|
|||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-32"
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
<AlbumCover id={item.Id} />
|
<AlbumCover id={item.Id} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
@@ -288,7 +343,7 @@ export default function search() {
|
|||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-32"
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
<AlbumCover id={item.AlbumId} />
|
<AlbumCover id={item.AlbumId} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
|
|||||||
@@ -1,20 +1,46 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
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";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
genreFilterAtom,
|
||||||
|
sortByAtom,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
sortOrderOptions,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
import {
|
||||||
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
ItemSortBy,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import {
|
||||||
import { useQuery } from "@tanstack/react-query";
|
getFilterApi,
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
getItemsApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { NativeScrollEvent, ScrollView, View } from "react-native";
|
||||||
|
|
||||||
|
const isCloseToBottom = ({
|
||||||
|
layoutMeasurement,
|
||||||
|
contentOffset,
|
||||||
|
contentSize,
|
||||||
|
}: NativeScrollEvent) => {
|
||||||
|
const paddingToBottom = 200;
|
||||||
|
return (
|
||||||
|
layoutMeasurement.height + contentOffset.y >=
|
||||||
|
contentSize.height - paddingToBottom
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -22,200 +48,294 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
|
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||||
|
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSortBy([
|
||||||
|
{
|
||||||
|
key: "ProductionYear",
|
||||||
|
value: "Production Year",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setSortOrder([
|
||||||
|
{
|
||||||
|
key: "Descending",
|
||||||
|
value: "Descending",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
const { data: collection } = useQuery({
|
||||||
queryKey: ["collection", collectionId],
|
queryKey: ["collection", collectionId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getUserLibraryApi(api).getItem({
|
||||||
|
itemId: collectionId,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
ids: [collectionId],
|
|
||||||
});
|
});
|
||||||
const data = response.data.Items?.[0];
|
const data = response.data;
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && !!collectionId,
|
enabled: !!api && !!user?.Id && !!collectionId,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [startIndex, setStartIndex] = useState<number>(0);
|
useEffect(() => {
|
||||||
|
navigation.setOptions({ title: collection?.Name || "" });
|
||||||
|
}, [navigation, collection]);
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery<{
|
const fetchItems = useCallback(
|
||||||
Items: BaseItemDto[];
|
async ({
|
||||||
TotalRecordCount: number;
|
pageParam,
|
||||||
}>({
|
}: {
|
||||||
queryKey: ["collection-items", collection?.Id, startIndex],
|
pageParam: number;
|
||||||
queryFn: async () => {
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
if (!api || !collectionId)
|
if (!api || !collection) return null;
|
||||||
return {
|
|
||||||
Items: [],
|
|
||||||
TotalRecordCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortBy: ItemSortBy[] = [];
|
|
||||||
const includeItemTypes: BaseItemKind[] = [];
|
|
||||||
|
|
||||||
switch (collection?.CollectionType) {
|
|
||||||
case "movies":
|
|
||||||
sortBy.push("SortName", "ProductionYear");
|
|
||||||
break;
|
|
||||||
case "boxsets":
|
|
||||||
sortBy.push("IsFolder", "SortName");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
sortBy.push("SortName");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (collection?.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:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
limit: 100,
|
limit: 18,
|
||||||
startIndex,
|
startIndex: pageParam,
|
||||||
sortBy,
|
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||||
sortOrder: ["Ascending"],
|
sortOrder: [sortOrder[0].key],
|
||||||
includeItemTypes,
|
fields: [
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
"ItemCounts",
|
||||||
recursive: true,
|
"PrimaryImageAspectRatio",
|
||||||
imageTypeLimit: 1,
|
"CanDelete",
|
||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
"MediaSourceCount",
|
||||||
|
],
|
||||||
|
genres: selectedGenres,
|
||||||
|
tags: selectedTags,
|
||||||
|
years: selectedYears.map((year) => parseInt(year)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = response.data.Items;
|
return response.data || null;
|
||||||
|
|
||||||
return {
|
|
||||||
Items: data || [],
|
|
||||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
enabled: !!collection?.Id && !!api && !!user?.Id,
|
[
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
collection,
|
||||||
|
selectedGenres,
|
||||||
|
selectedYears,
|
||||||
|
selectedTags,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
|
||||||
|
queryKey: [
|
||||||
|
"collection-items",
|
||||||
|
collection,
|
||||||
|
selectedGenres,
|
||||||
|
selectedYears,
|
||||||
|
selectedTags,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
],
|
||||||
|
queryFn: fetchItems,
|
||||||
|
getNextPageParam: (lastPage, pages) => {
|
||||||
|
if (
|
||||||
|
!lastPage?.Items ||
|
||||||
|
!lastPage?.TotalRecordCount ||
|
||||||
|
lastPage?.TotalRecordCount === 0
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
|
const accumulatedItems = pages.reduce(
|
||||||
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accumulatedItems < totalItems) {
|
||||||
|
return lastPage?.Items?.length * pages.length;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: !!api && !!user?.Id && !!collection,
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalItems = useMemo(() => {
|
useEffect(() => {
|
||||||
return data?.TotalRecordCount;
|
console.log("Data: ", data);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
const type = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const flatData = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((p) => p?.Items) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView>
|
<ScrollView
|
||||||
<View>
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
<View className="px-4 mb-4">
|
onScroll={({ nativeEvent }) => {
|
||||||
<Text className="font-bold text-3xl mb-2">{collection?.Name}</Text>
|
if (isCloseToBottom(nativeEvent)) {
|
||||||
<View className="flex flex-row items-center justify-between">
|
fetchNextPage();
|
||||||
<Text>
|
}
|
||||||
{startIndex + 1}-{Math.min(startIndex + 100, totalItems || 0)} of{" "}
|
}}
|
||||||
{totalItems}
|
scrollEventThrottle={400}
|
||||||
</Text>
|
>
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="mt-4 mb-24">
|
||||||
<TouchableOpacity
|
<View className="mb-4">
|
||||||
onPress={() => {
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
setStartIndex((prev) => Math.max(prev - 100, 0));
|
<View className="flex flex-row space-x-1 px-3">
|
||||||
|
<ResetFiltersButton />
|
||||||
|
<FilterButton
|
||||||
|
collectionId={collectionId}
|
||||||
|
queryKey="genreFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: type ? [type] : [],
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Genres || [];
|
||||||
}}
|
}}
|
||||||
>
|
set={setSelectedGenres}
|
||||||
<Ionicons
|
values={selectedGenres}
|
||||||
name="arrow-back-circle-outline"
|
title="Genres"
|
||||||
size={32}
|
renderItemLabel={(item) => item.toString()}
|
||||||
color="white"
|
searchFilter={(item, search) =>
|
||||||
/>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
</TouchableOpacity>
|
}
|
||||||
<TouchableOpacity
|
/>
|
||||||
onPress={() => {
|
<FilterButton
|
||||||
setStartIndex((prev) => prev + 100);
|
collectionId={collectionId}
|
||||||
|
queryKey="tagsFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: type ? [type] : [],
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Tags || [];
|
||||||
}}
|
}}
|
||||||
>
|
set={setSelectedTags}
|
||||||
<Ionicons
|
values={selectedTags}
|
||||||
name="arrow-forward-circle-outline"
|
title="Tags"
|
||||||
size={32}
|
renderItemLabel={(item) => item.toString()}
|
||||||
color="white"
|
searchFilter={(item, search) =>
|
||||||
/>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
</TouchableOpacity>
|
}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
collectionId={collectionId}
|
||||||
|
queryKey="yearFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: type ? [type] : [],
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
response.data.Years?.sort((a, b) => b - a).map((y) =>
|
||||||
|
y.toString()
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
set={setSelectedYears}
|
||||||
|
values={selectedYears}
|
||||||
|
title="Years"
|
||||||
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
icon="sort"
|
||||||
|
collectionId={collectionId}
|
||||||
|
queryKey="sortByFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
return sortOptions;
|
||||||
|
}}
|
||||||
|
set={setSortBy}
|
||||||
|
values={sortBy}
|
||||||
|
title="Sort by"
|
||||||
|
renderItemLabel={(item) => item.value}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
icon="sort"
|
||||||
|
showSearch={false}
|
||||||
|
collectionId={collectionId}
|
||||||
|
queryKey="orderByFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
return sortOrderOptions;
|
||||||
|
}}
|
||||||
|
set={setSortOrder}
|
||||||
|
values={sortOrder}
|
||||||
|
title="Order by"
|
||||||
|
renderItemLabel={(item) => item.value}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</ScrollView>
|
||||||
|
{!type && isFetching && (
|
||||||
|
<Loader
|
||||||
|
style={{
|
||||||
|
marginTop: 300,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row flex-wrap px-4 justify-between after:content-['']">
|
||||||
|
{flatData.map(
|
||||||
|
(item, index) =>
|
||||||
|
item && (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={`${item.Id}`}
|
||||||
|
style={{
|
||||||
|
width: "32%",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
item={item}
|
||||||
|
className={`
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{flatData.length % 3 !== 0 && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "33%",
|
||||||
|
}}
|
||||||
|
></View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
{isLoading ? (
|
|
||||||
<View className="my-12">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className="flex flex-row flex-wrap">
|
|
||||||
{data?.Items?.map((item: BaseItemDto, index: number) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={{
|
|
||||||
maxWidth: "33%",
|
|
||||||
width: "100%",
|
|
||||||
padding: 10,
|
|
||||||
}}
|
|
||||||
key={index}
|
|
||||||
onPress={() => {
|
|
||||||
if (item?.Type === "Series") {
|
|
||||||
router.push(`/series/${item.Id}`);
|
|
||||||
} else if (item.IsFolder) {
|
|
||||||
router.push(`/collections/${item?.Id}`);
|
|
||||||
} else {
|
|
||||||
router.push(`/items/${item.Id}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col gap-y-2">
|
|
||||||
{collection?.CollectionType === "movies" ? (
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
) : collection?.CollectionType === "music" ? (
|
|
||||||
<ArtistPoster item={item} />
|
|
||||||
) : (
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
)}
|
|
||||||
<Text>{item.Name}</Text>
|
|
||||||
<Text className="opacity-50 text-xs">
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
{!isLoading && (
|
|
||||||
<View className="flex flex-row items-center space-x-2 justify-center mt-4 mb-12">
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
setStartIndex((prev) => Math.max(prev - 100, 0));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="arrow-back-circle-outline"
|
|
||||||
size={32}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
setStartIndex((prev) => prev + 100);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="arrow-forward-circle-outline"
|
|
||||||
size={32}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
import {
|
|
||||||
currentlyPlayingItemAtom,
|
|
||||||
fullScreenAtom,
|
|
||||||
playingAtom,
|
|
||||||
} from "@/components/CurrentlyPlayingBar";
|
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
import { DownloadItem } from "@/components/DownloadItem";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
@@ -20,6 +15,12 @@ import { CurrentSeries } from "@/components/series/CurrentSeries";
|
|||||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||||
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
|
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
currentlyPlayingItemAtom,
|
||||||
|
fullScreenAtom,
|
||||||
|
playingAtom,
|
||||||
|
showCurrentlyPlayingBarAtom,
|
||||||
|
} from "@/utils/atoms/playState";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
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 { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
@@ -55,6 +56,7 @@ const page: React.FC = () => {
|
|||||||
const castDevice = useCastDevice();
|
const castDevice = useCastDevice();
|
||||||
|
|
||||||
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
|
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
|
||||||
|
const [, setShowCurrentlyPlayingBar] = useAtom(showCurrentlyPlayingBarAtom);
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
const [, setPlaying] = useAtom(playingAtom);
|
||||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
const [, setFullscreen] = useAtom(fullScreenAtom);
|
||||||
|
|
||||||
@@ -168,8 +170,12 @@ const page: React.FC = () => {
|
|||||||
playbackUrl,
|
playbackUrl,
|
||||||
});
|
});
|
||||||
setPlaying(true);
|
setPlaying(true);
|
||||||
|
setShowCurrentlyPlayingBar(true);
|
||||||
|
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
||||||
setFullscreen(true);
|
setTimeout(() => {
|
||||||
|
setFullscreen(true);
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -274,12 +280,7 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
<View className="flex flex-row items-center justify-between w-full">
|
<View className="flex flex-row items-center justify-between w-full">
|
||||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||||
<PlayButton
|
<PlayButton item={item} url={playbackUrl} className="grow" />
|
||||||
item={item}
|
|
||||||
chromecastReady={chromecastReady}
|
|
||||||
onPress={onPressPlay}
|
|
||||||
className="grow"
|
|
||||||
/>
|
|
||||||
<NextEpisodeButton item={item} className="ml-2" />
|
<NextEpisodeButton item={item} className="ml-2" />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ScrollView, View } from "react-native";
|
|||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
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();
|
||||||
@@ -44,7 +45,7 @@ export default function settings() {
|
|||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await deleteAllFiles();
|
await deleteAllFiles();
|
||||||
Haptics.notificationAsync(
|
Haptics.notificationAsync(
|
||||||
Haptics.NotificationFeedbackType.Success,
|
Haptics.NotificationFeedbackType.Success
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -55,7 +56,7 @@ export default function settings() {
|
|||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await clearLogs();
|
await clearLogs();
|
||||||
Haptics.notificationAsync(
|
Haptics.notificationAsync(
|
||||||
Haptics.NotificationFeedbackType.Success,
|
Haptics.NotificationFeedbackType.Success
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
214
app/_layout.tsx
214
app/_layout.tsx
@@ -1,22 +1,22 @@
|
|||||||
|
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||||
|
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||||
|
import { PlaybackProvider } from "@/providers/PlaybackProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
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 { Stack } from "expo-router";
|
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import "react-native-reanimated";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { StatusBar } from "expo-status-bar";
|
|
||||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
|
||||||
import { useJobProcessor } from "@/utils/atoms/queue";
|
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
|
||||||
import { useKeepAwake } from "expo-keep-awake";
|
import { useKeepAwake } from "expo-keep-awake";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { Stack } from "expo-router";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import "react-native-reanimated";
|
||||||
|
|
||||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
@@ -82,99 +82,101 @@ function Layout() {
|
|||||||
<ActionSheetProvider>
|
<ActionSheetProvider>
|
||||||
<BottomSheetModalProvider>
|
<BottomSheetModalProvider>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<StatusBar style="light" backgroundColor="#000" />
|
<PlaybackProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<StatusBar style="light" backgroundColor="#000" />
|
||||||
<Stack initialRouteName="/home">
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack.Screen
|
<Stack initialRouteName="/home">
|
||||||
name="(auth)/(tabs)"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/(tabs)"
|
||||||
headerShown: false,
|
options={{
|
||||||
title: "",
|
headerShown: false,
|
||||||
}}
|
title: "",
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/settings"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/settings"
|
||||||
headerShown: true,
|
options={{
|
||||||
title: "Settings",
|
headerShown: true,
|
||||||
headerStyle: { backgroundColor: "black" },
|
title: "Settings",
|
||||||
headerShadowVisible: false,
|
headerStyle: { backgroundColor: "black" },
|
||||||
}}
|
headerShadowVisible: false,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/downloads"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/downloads"
|
||||||
headerShown: true,
|
options={{
|
||||||
title: "Downloads",
|
headerShown: true,
|
||||||
headerStyle: { backgroundColor: "black" },
|
title: "Downloads",
|
||||||
headerShadowVisible: false,
|
headerStyle: { backgroundColor: "black" },
|
||||||
}}
|
headerShadowVisible: false,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/items/[id]"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/items/[id]"
|
||||||
title: "",
|
options={{
|
||||||
headerShown: false,
|
title: "",
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/collections/[collectionId]"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/collections/[collectionId]"
|
||||||
title: "",
|
options={{
|
||||||
headerShown: true,
|
title: "",
|
||||||
headerStyle: { backgroundColor: "black" },
|
headerShown: true,
|
||||||
headerShadowVisible: false,
|
headerStyle: { backgroundColor: "black" },
|
||||||
}}
|
headerShadowVisible: false,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/artists/page"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/artists/page"
|
||||||
title: "",
|
options={{
|
||||||
headerShown: true,
|
title: "",
|
||||||
headerStyle: { backgroundColor: "black" },
|
headerShown: true,
|
||||||
headerShadowVisible: false,
|
headerStyle: { backgroundColor: "black" },
|
||||||
}}
|
headerShadowVisible: false,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/artists/[artistId]/page"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/artists/[artistId]/page"
|
||||||
title: "",
|
options={{
|
||||||
headerShown: true,
|
title: "",
|
||||||
headerStyle: { backgroundColor: "black" },
|
headerShown: true,
|
||||||
headerShadowVisible: false,
|
headerStyle: { backgroundColor: "black" },
|
||||||
}}
|
headerShadowVisible: false,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/albums/[albumId]"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/albums/[albumId]"
|
||||||
title: "",
|
options={{
|
||||||
headerShown: true,
|
title: "",
|
||||||
headerStyle: { backgroundColor: "black" },
|
headerShown: true,
|
||||||
headerShadowVisible: false,
|
headerStyle: { backgroundColor: "black" },
|
||||||
}}
|
headerShadowVisible: false,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/songs/[songId]"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/songs/[songId]"
|
||||||
title: "",
|
options={{
|
||||||
headerShown: false,
|
title: "",
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/series/[id]"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/series/[id]"
|
||||||
title: "",
|
options={{
|
||||||
headerShown: false,
|
title: "",
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="login"
|
<Stack.Screen
|
||||||
options={{ headerShown: false, title: "Login" }}
|
name="login"
|
||||||
/>
|
options={{ headerShown: false, title: "Login" }}
|
||||||
<Stack.Screen name="+not-found" />
|
/>
|
||||||
</Stack>
|
<Stack.Screen name="+not-found" />
|
||||||
<CurrentlyPlayingBar />
|
</Stack>
|
||||||
</ThemeProvider>
|
<CurrentlyPlayingBar />
|
||||||
|
</ThemeProvider>
|
||||||
|
</PlaybackProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
</ActionSheetProvider>
|
</ActionSheetProvider>
|
||||||
|
|||||||
40
assets/Download_on_the_App_Store_Badge_DE_RGB_blk_092917.svg
Normal file
40
assets/Download_on_the_App_Store_Badge_DE_RGB_blk_092917.svg
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
|
||||||
|
<title>Download_on_the_App_Store_Badge_DE_RGB_blk_092917</title>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
|
||||||
|
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
|
||||||
|
</g>
|
||||||
|
<g id="_Group_" data-name="<Group>">
|
||||||
|
<g id="_Group_2" data-name="<Group>">
|
||||||
|
<g id="_Group_3" data-name="<Group>">
|
||||||
|
<path id="_Path_" data-name="<Path>" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
|
||||||
|
<path id="_Path_2" data-name="<Path>" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
|
||||||
|
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
|
||||||
|
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
|
||||||
|
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
|
||||||
|
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
|
||||||
|
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
|
||||||
|
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
|
||||||
|
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="_Group_4" data-name="<Group>">
|
||||||
|
<g>
|
||||||
|
<path d="M39.3926,14.69775H35.67092V8.731h.92676V13.8457H39.3926Z" style="fill: #fff"/>
|
||||||
|
<path d="M40.32912,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,40.32912,13.42432Zm2.89453-.38477v-.37646L42.124,12.7334c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,43.22365,13.03955Z" style="fill: #fff"/>
|
||||||
|
<path d="M45.27639,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074H48.6299v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C46,14.772,45.27639,13.87061,45.27639,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C46.64943,10.91846,46.19436,11.49707,46.19436,12.44434Z" style="fill: #fff"/>
|
||||||
|
<path d="M54.74709,13.48193a1.828,1.828,0,0,1-1.95117,1.30273,2.04531,2.04531,0,0,1-2.08008-2.32422A2.07685,2.07685,0,0,1,52.792,10.10791c1.25293,0,2.00879.856,2.00879,2.27V12.688H51.62111v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,51.62111,12.03076Z" style="fill: #fff"/>
|
||||||
|
<path d="M55.99416,10.19482h.85547v.71533H56.916a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
|
||||||
|
<path d="M63.51955,8.86328a.57572.57572,0,1,1,.5752.5415A.54735.54735,0,0,1,63.51955,8.86328Zm.13281,1.33154h.88477v4.50293h-.88477Z" style="fill: #fff"/>
|
||||||
|
<path d="M65.97121,10.19482h.85547v.72363h.06641a1.36385,1.36385,0,0,1,2.49316,0h.07031a1.46325,1.46325,0,0,1,1.36914-.81055,1.33821,1.33821,0,0,1,1.43848,1.48828v3.10156h-.88867V11.82813c0-.60791-.29-.90576-.873-.90576a.91167.91167,0,0,0-.9502.94287v2.83252h-.873V11.74121a.78468.78468,0,0,0-.86816-.81885.96854.96854,0,0,0-.95117,1.02148v2.75391h-.88867Z" style="fill: #fff"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.0 KiB |
2
assets/Google_Play_Store_badge_EN.svg
Normal file
2
assets/Google_Play_Store_badge_EN.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.1 KiB |
@@ -21,23 +21,23 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
getPrimaryImageUrl({
|
getPrimaryImageUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
quality: 70,
|
quality: 90,
|
||||||
width: 300,
|
width: 176 * 2,
|
||||||
}),
|
}),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [progress, setProgress] = useState(
|
const [progress, setProgress] = useState(
|
||||||
item.UserData?.PlayedPercentage || 0,
|
item.UserData?.PlayedPercentage || 0
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
return (
|
return (
|
||||||
<View className="w-48 aspect-video border border-neutral-800"></View>
|
<View className="w-44 aspect-video border border-neutral-800"></View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="w-48 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
<View className="w-44 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||||
<Image
|
<Image
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
id={item.Id}
|
id={item.Id}
|
||||||
|
|||||||
@@ -1,50 +1,40 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
|
||||||
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { atom, useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import Video, { OnProgressData, VideoRef } 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 { VLCPlayer, VlCPlayerView } from "react-native-vlc-media-player";
|
||||||
export const currentlyPlayingItemAtom = atom<{
|
|
||||||
item: BaseItemDto;
|
|
||||||
playbackUrl: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
export const playingAtom = atom(false);
|
|
||||||
export const fullScreenAtom = atom(false);
|
|
||||||
|
|
||||||
export const CurrentlyPlayingBar: React.FC = () => {
|
export const CurrentlyPlayingBar: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
|
const {
|
||||||
|
currentlyPlaying,
|
||||||
|
pauseVideo,
|
||||||
|
playVideo,
|
||||||
|
setCurrentlyPlayingState,
|
||||||
|
stopPlayback,
|
||||||
|
setIsPlaying,
|
||||||
|
isPlaying,
|
||||||
|
videoRef,
|
||||||
|
onProgress,
|
||||||
|
} = usePlayback();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [playing, setPlaying] = useAtom(playingAtom);
|
|
||||||
const [currentlyPlaying, setCurrentlyPlaying] = useAtom(
|
|
||||||
currentlyPlayingItemAtom
|
|
||||||
);
|
|
||||||
const [fullScreen, setFullScreen] = useAtom(fullScreenAtom);
|
|
||||||
|
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
|
|
||||||
const aBottom = useSharedValue(0);
|
const aBottom = useSharedValue(0);
|
||||||
const aPadding = useSharedValue(0);
|
const aPadding = useSharedValue(0);
|
||||||
@@ -92,108 +82,28 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [segments]);
|
}, [segments]);
|
||||||
|
|
||||||
const { data: item } = useQuery({
|
|
||||||
queryKey: ["item", currentlyPlaying?.item.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
await getUserItemData({
|
|
||||||
api,
|
|
||||||
userId: user?.Id,
|
|
||||||
itemId: currentlyPlaying?.item.Id,
|
|
||||||
}),
|
|
||||||
enabled: !!currentlyPlaying?.item.Id && !!api,
|
|
||||||
staleTime: 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: sessionData } = useQuery({
|
|
||||||
queryKey: ["sessionData", currentlyPlaying?.item.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!currentlyPlaying?.item.Id) return null;
|
|
||||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
|
||||||
itemId: currentlyPlaying?.item.Id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
return playbackData.data;
|
|
||||||
},
|
|
||||||
enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
({ currentTime }: OnProgressData) => {
|
|
||||||
if (
|
|
||||||
!currentTime ||
|
|
||||||
!sessionData?.PlaySessionId ||
|
|
||||||
!playing ||
|
|
||||||
!api ||
|
|
||||||
!currentlyPlaying?.item.Id
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
const newProgress = currentTime * 10000000;
|
|
||||||
setProgress(newProgress);
|
|
||||||
reportPlaybackProgress({
|
|
||||||
api,
|
|
||||||
itemId: currentlyPlaying?.item.Id,
|
|
||||||
positionTicks: newProgress,
|
|
||||||
sessionId: sessionData.PlaySessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!item || !api) return;
|
|
||||||
|
|
||||||
if (playing) {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
} else {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
if (progress > 0 && sessionData?.PlaySessionId)
|
|
||||||
reportPlaybackStopped({
|
|
||||||
api,
|
|
||||||
itemId: item?.Id,
|
|
||||||
positionTicks: progress,
|
|
||||||
sessionId: sessionData?.PlaySessionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["nextUp", item?.SeriesId],
|
|
||||||
refetchType: "all",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["episodes"],
|
|
||||||
refetchType: "all",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [playing, progress, item, sessionData]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (fullScreen === true) {
|
|
||||||
videoRef.current?.presentFullscreenPlayer();
|
|
||||||
} else {
|
|
||||||
videoRef.current?.dismissFullscreenPlayer();
|
|
||||||
}
|
|
||||||
}, [fullScreen]);
|
|
||||||
|
|
||||||
const startPosition = useMemo(
|
const startPosition = useMemo(
|
||||||
() =>
|
() =>
|
||||||
item?.UserData?.PlaybackPositionTicks
|
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
|
||||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
? Math.round(
|
||||||
|
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
|
||||||
|
)
|
||||||
: 0,
|
: 0,
|
||||||
[item]
|
[currentlyPlaying?.item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
const backdropUrl = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getBackdropUrl({
|
getBackdropUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item: currentlyPlaying?.item,
|
||||||
quality: 70,
|
quality: 70,
|
||||||
width: 200,
|
width: 200,
|
||||||
}),
|
}),
|
||||||
[item]
|
[currentlyPlaying?.item, api]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!currentlyPlaying || !api) return null;
|
if (!api || !currentlyPlaying) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -220,113 +130,129 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
videoRef.current?.presentFullscreenPlayer();
|
videoRef.current?.presentFullscreenPlayer();
|
||||||
}}
|
}}
|
||||||
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
||||||
${item?.Type === "Audio" ? "aspect-square" : "aspect-video"}
|
${
|
||||||
|
currentlyPlaying.item?.Type === "Audio"
|
||||||
|
? "aspect-square"
|
||||||
|
: "aspect-video"
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{currentlyPlaying.playbackUrl && (
|
{currentlyPlaying?.url && (
|
||||||
<Video
|
// <Video
|
||||||
ref={videoRef}
|
// ref={videoRef}
|
||||||
allowsExternalPlayback
|
// allowsExternalPlayback
|
||||||
style={{ width: "100%", height: "100%" }}
|
// style={{ width: "100%", height: "100%" }}
|
||||||
playWhenInactive={true}
|
// playWhenInactive={true}
|
||||||
playInBackground={true}
|
// playInBackground={true}
|
||||||
showNotificationControls={true}
|
// showNotificationControls={true}
|
||||||
ignoreSilentSwitch="ignore"
|
// ignoreSilentSwitch="ignore"
|
||||||
controls={false}
|
// controls={false}
|
||||||
pictureInPicture={true}
|
// pictureInPicture={true}
|
||||||
poster={
|
// poster={
|
||||||
backdropUrl && item?.Type === "Audio"
|
// backdropUrl && currentlyPlaying.item?.Type === "Audio"
|
||||||
? backdropUrl
|
// ? backdropUrl
|
||||||
: undefined
|
// : undefined
|
||||||
}
|
// }
|
||||||
debug={{
|
// debug={{
|
||||||
enable: true,
|
// enable: true,
|
||||||
thread: true,
|
// thread: true,
|
||||||
}}
|
// }}
|
||||||
paused={!playing}
|
// paused={!isPlaying}
|
||||||
onProgress={(e) => onProgress(e)}
|
// onProgress={(e) => onProgress(e)}
|
||||||
subtitleStyle={{
|
// subtitleStyle={{
|
||||||
fontSize: 16,
|
// fontSize: 16,
|
||||||
|
// }}
|
||||||
|
// source={{
|
||||||
|
// uri: currentlyPlaying.url,
|
||||||
|
// isNetwork: true,
|
||||||
|
// startPosition,
|
||||||
|
// headers: getAuthHeaders(api),
|
||||||
|
// }}
|
||||||
|
// onBuffer={(e) =>
|
||||||
|
// e.isBuffering ? console.log("Buffering...") : null
|
||||||
|
// }
|
||||||
|
// onFullscreenPlayerDidDismiss={() => {}}
|
||||||
|
// onFullscreenPlayerDidPresent={() => {}}
|
||||||
|
// onPlaybackStateChanged={(e) => {
|
||||||
|
// if (e.isPlaying) {
|
||||||
|
// setIsPlaying(true);
|
||||||
|
// } else if (e.isSeeking) {
|
||||||
|
// return;
|
||||||
|
// } else {
|
||||||
|
// setIsPlaying(false);
|
||||||
|
// }
|
||||||
|
// }}
|
||||||
|
// progressUpdateInterval={2000}
|
||||||
|
// onError={(e) => {
|
||||||
|
// console.log(e);
|
||||||
|
// writeToLog(
|
||||||
|
// "ERROR",
|
||||||
|
// "Video playback error: " + JSON.stringify(e)
|
||||||
|
// );
|
||||||
|
// Alert.alert("Error", "Cannot play this video file.");
|
||||||
|
// setIsPlaying(false);
|
||||||
|
// // setCurrentlyPlaying(null);
|
||||||
|
// }}
|
||||||
|
// renderLoader={
|
||||||
|
// currentlyPlaying.item?.Type !== "Audio" && (
|
||||||
|
// <View className="flex flex-col items-center justify-center h-full">
|
||||||
|
// <Loader />
|
||||||
|
// </View>
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// />
|
||||||
|
<VlCPlayerView
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
source={{
|
source={{
|
||||||
uri: currentlyPlaying.playbackUrl,
|
uri: encodeURIComponent(currentlyPlaying.url),
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
}}
|
}}
|
||||||
onBuffer={(e) =>
|
key={"1"}
|
||||||
e.isBuffering ? console.log("Buffering...") : null
|
autoAspectRatio={true}
|
||||||
}
|
resizeMode="cover"
|
||||||
onFullscreenPlayerDidDismiss={() => {
|
|
||||||
setFullScreen(false);
|
|
||||||
}}
|
|
||||||
onFullscreenPlayerDidPresent={() => {
|
|
||||||
setFullScreen(true);
|
|
||||||
}}
|
|
||||||
onPlaybackStateChanged={(e) => {
|
|
||||||
if (e.isPlaying) {
|
|
||||||
setPlaying(true);
|
|
||||||
} else if (e.isSeeking) {
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
setPlaying(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
progressUpdateInterval={1000}
|
|
||||||
onError={(e) => {
|
|
||||||
console.log(e);
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
"Video playback error: " + JSON.stringify(e)
|
|
||||||
);
|
|
||||||
Alert.alert("Error", "Cannot play this video file.");
|
|
||||||
setPlaying(false);
|
|
||||||
setCurrentlyPlaying(null);
|
|
||||||
}}
|
|
||||||
renderLoader={
|
|
||||||
item?.Type !== "Audio" && (
|
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View className="shrink text-xs">
|
<View className="shrink text-xs">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (item?.Type === "Audio")
|
if (currentlyPlaying.item?.Type === "Audio")
|
||||||
router.push(`/albums/${item?.AlbumId}`);
|
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
||||||
else router.push(`/items/${item?.Id}`);
|
else router.push(`/items/${currentlyPlaying.item?.Id}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text>{item?.Name}</Text>
|
<Text>{currentlyPlaying.item?.Name}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{item?.Type === "Episode" && (
|
{currentlyPlaying.item?.Type === "Episode" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push(`/(auth)/series/${item.SeriesId}`);
|
router.push(
|
||||||
|
`/(auth)/series/${currentlyPlaying.item.SeriesId}`
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
className="text-xs opacity-50"
|
className="text-xs opacity-50"
|
||||||
>
|
>
|
||||||
<Text>{item.SeriesName}</Text>
|
<Text>{currentlyPlaying.item.SeriesName}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{item?.Type === "Movie" && (
|
{currentlyPlaying.item?.Type === "Movie" && (
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-xs opacity-50">
|
<Text className="text-xs opacity-50">
|
||||||
{item?.ProductionYear}
|
{currentlyPlaying.item?.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{item?.Type === "Audio" && (
|
{currentlyPlaying.item?.Type === "Audio" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push(`/albums/${item?.AlbumId}`);
|
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="text-xs opacity-50">{item?.Album}</Text>
|
<Text className="text-xs opacity-50">
|
||||||
|
{currentlyPlaying.item?.Album}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -334,12 +260,12 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (playing) setPlaying(false);
|
if (isPlaying) pauseVideo();
|
||||||
else setPlaying(true);
|
else playVideo();
|
||||||
}}
|
}}
|
||||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||||
>
|
>
|
||||||
{playing ? (
|
{isPlaying ? (
|
||||||
<Ionicons name="pause" size={24} color="white" />
|
<Ionicons name="pause" size={24} color="white" />
|
||||||
) : (
|
) : (
|
||||||
<Ionicons name="play" size={24} color="white" />
|
<Ionicons name="play" size={24} color="white" />
|
||||||
@@ -347,7 +273,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setCurrentlyPlaying(null);
|
stopPlayback();
|
||||||
}}
|
}}
|
||||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,17 +8,6 @@ type ItemCardProps = {
|
|||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
};
|
};
|
||||||
|
|
||||||
function seasonNameToIndex(seasonName: string | null | undefined) {
|
|
||||||
if (!seasonName) return -1;
|
|
||||||
if (seasonName.startsWith("Season")) {
|
|
||||||
return parseInt(seasonName.replace("Season ", ""));
|
|
||||||
}
|
|
||||||
if (seasonName.startsWith("Specials")) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||||
return (
|
return (
|
||||||
<View className="mt-2 flex flex-col h-12">
|
<View className="mt-2 flex flex-col h-12">
|
||||||
@@ -28,9 +17,7 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
|||||||
{item.SeriesName}
|
{item.SeriesName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||||
{`S${seasonNameToIndex(
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
|
||||||
item?.SeasonName,
|
|
||||||
)}:E${item.IndexNumber?.toString()}`}{" "}
|
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,27 +1,30 @@
|
|||||||
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
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 { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import CastContext, {
|
||||||
|
PlayServicesState,
|
||||||
|
useRemoteMediaClient,
|
||||||
|
} from "react-native-google-cast";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item?: BaseItemDto | null;
|
||||||
onPress: (type?: "cast" | "device") => void;
|
url?: string | null;
|
||||||
chromecastReady: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayButton: React.FC<Props> = ({
|
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||||
item,
|
|
||||||
onPress,
|
|
||||||
chromecastReady,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const client = useRemoteMediaClient();
|
||||||
|
const { currentlyPlaying, setCurrentlyPlayingState } = usePlayback();
|
||||||
|
|
||||||
const _onPress = () => {
|
const onPress = async () => {
|
||||||
if (!chromecastReady) {
|
if (!url || !item) return;
|
||||||
onPress("device");
|
|
||||||
|
if (!client) {
|
||||||
|
setCurrentlyPlayingState({ item, url });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,28 +36,45 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
options,
|
options,
|
||||||
cancelButtonIndex,
|
cancelButtonIndex,
|
||||||
},
|
},
|
||||||
(selectedIndex: number | undefined) => {
|
async (selectedIndex: number | undefined) => {
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
onPress("cast");
|
await CastContext.getPlayServicesState().then((state) => {
|
||||||
|
if (state && state !== PlayServicesState.SUCCESS)
|
||||||
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
|
else {
|
||||||
|
client.loadMedia({
|
||||||
|
mediaInfo: {
|
||||||
|
contentUrl: url,
|
||||||
|
contentType: "video/mp4",
|
||||||
|
metadata: {
|
||||||
|
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||||
|
title: item.Name || "",
|
||||||
|
subtitle: item.Overview || "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
onPress("device");
|
setCurrentlyPlayingState({ item, url });
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onPress={_onPress}
|
onPress={onPress}
|
||||||
iconRight={
|
iconRight={
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Ionicons name="play-circle" size={24} color="white" />
|
<Ionicons name="play-circle" size={24} color="white" />
|
||||||
{chromecastReady && <Feather name="cast" size={22} color="white" />}
|
{client && <Feather name="cast" size={22} color="white" />}
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Movies and all other cases
|
|
||||||
if (item.Type === "BoxSet") {
|
if (item.Type === "BoxSet") {
|
||||||
router.push(`/collections/${item.Id}`);
|
router.push(`/collections/${item.Id}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import { useAtom } from "jotai";
|
|||||||
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
currentlyPlayingItemAtom,
|
currentlyPlayingItemAtom,
|
||||||
fullScreenAtom,
|
fullScreenAtom,
|
||||||
playingAtom,
|
playingAtom,
|
||||||
} from "../CurrentlyPlayingBar";
|
} from "@/utils/atoms/playState";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
interface EpisodeCardProps {
|
interface EpisodeCardProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import { useAtom } from "jotai";
|
|||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
currentlyPlayingItemAtom,
|
currentlyPlayingItemAtom,
|
||||||
fullScreenAtom,
|
|
||||||
playingAtom,
|
playingAtom,
|
||||||
} from "../CurrentlyPlayingBar";
|
fullScreenAtom,
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
} from "@/utils/atoms/playState";
|
||||||
|
|
||||||
interface MovieCardProps {
|
interface MovieCardProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -81,7 +82,12 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ContextMenu.Trigger>
|
</ContextMenu.Trigger>
|
||||||
<ContextMenu.Content>
|
<ContextMenu.Content
|
||||||
|
loop={false}
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={false}
|
||||||
|
collisionPadding={0}
|
||||||
|
>
|
||||||
{contextMenuOptions.map((option) => (
|
{contextMenuOptions.map((option) => (
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
key={option.label}
|
key={option.label}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { FilterSheet } from "./FilterSheet";
|
import { FilterSheet } from "./FilterSheet";
|
||||||
@@ -59,7 +57,7 @@ export const FilterButton = <T,>({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
|
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
|
||||||
text-xs font-semibold`}
|
text-xs font-semibold`}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export const FilterSheet = <T,>({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, 250);
|
}, 250);
|
||||||
}}
|
}}
|
||||||
key={index}
|
key={`${index}`}
|
||||||
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>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
key={index}
|
key={index}
|
||||||
item={item}
|
item={item}
|
||||||
className={`flex flex-col
|
className={`flex flex-col
|
||||||
${orientation === "vertical" ? "w-32" : "w-48"}
|
${orientation === "vertical" ? "w-28" : "w-44"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
|
|||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import MoviePoster from "../posters/MoviePoster";
|
import MoviePoster from "../posters/MoviePoster";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
collection: BaseItemDto;
|
collection: BaseItemDto;
|
||||||
@@ -56,11 +57,12 @@ export const MediaListSection: React.FC<Props> = ({ collection, ...props }) => {
|
|||||||
key={index}
|
key={index}
|
||||||
item={item}
|
item={item}
|
||||||
className={`flex flex-col
|
className={`flex flex-col
|
||||||
${"w-32"}
|
${"w-28"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<MoviePoster item={item} />
|
<MoviePoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import Poster from "../posters/Poster";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
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 { router } from "expo-router";
|
import { router, usePathname } from "expo-router";
|
||||||
|
|
||||||
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<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>
|
||||||
@@ -23,7 +25,7 @@ export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
|||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
// TODO: Navigate to person
|
router.push(`/search?q=${item.Name}&prev=${pathname}`);
|
||||||
}}
|
}}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-32"
|
className="flex flex-col w-32"
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
|||||||
router.push(`/(auth)/items/${item.Id}`);
|
router.push(`/(auth)/items/${item.Id}`);
|
||||||
}}
|
}}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-32"
|
className="flex flex-col w-44"
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
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 { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Linking, Switch, TouchableOpacity, View } from "react-native";
|
import { Linking, Switch, 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 { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
|
import { Input } from "../common/Input";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
|
||||||
export const SettingToggles: React.FC = () => {
|
export const SettingToggles: React.FC = () => {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
@@ -14,6 +17,10 @@ export const SettingToggles: React.FC = () => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const [marlinUrl, setMarlinUrl] = useState<string>("");
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: mediaListCollections,
|
data: mediaListCollections,
|
||||||
isLoading: isLoadingMediaListCollections,
|
isLoading: isLoadingMediaListCollections,
|
||||||
@@ -208,6 +215,89 @@ export const SettingToggles: React.FC = () => {
|
|||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<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">Search engine</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?.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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.6.1",
|
"channel": "0.6.2",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.6.1",
|
"channel": "0.6.2",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"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.4.3",
|
"react-native-video": "^6.4.3",
|
||||||
|
"react-native-vlc-media-player": "^1.0.69",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
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 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 { isLoaded } from "expo-font";
|
|
||||||
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, {
|
||||||
@@ -21,6 +25,7 @@ interface Server {
|
|||||||
|
|
||||||
export const apiAtom = atom<Api | null>(null);
|
export const apiAtom = atom<Api | null>(null);
|
||||||
export const userAtom = atom<UserDto | null>(null);
|
export const userAtom = atom<UserDto | null>(null);
|
||||||
|
export const wsAtom = atom<WebSocket | null>(null);
|
||||||
|
|
||||||
interface JellyfinContextValue {
|
interface JellyfinContextValue {
|
||||||
discoverServers: (url: string) => Promise<Server[]>;
|
discoverServers: (url: string) => Promise<Server[]>;
|
||||||
@@ -31,7 +36,7 @@ interface JellyfinContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
||||||
undefined,
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const getOrSetDeviceId = async () => {
|
const getOrSetDeviceId = async () => {
|
||||||
@@ -49,6 +54,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
|
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
|
||||||
|
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
|
||||||
|
const [isConnected, setIsConnected] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -56,10 +63,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.6.1" },
|
clientInfo: { name: "Streamyfin", version: "0.6.2" },
|
||||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
setDeviceId(id);
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -67,8 +75,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const [user, setUser] = useAtom(userAtom);
|
const [user, setUser] = useAtom(userAtom);
|
||||||
|
|
||||||
const discoverServers = async (url: string): Promise<Server[]> => {
|
const discoverServers = async (url: string): Promise<Server[]> => {
|
||||||
const servers =
|
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
|
||||||
await jellyfin?.discovery.getRecommendedServerCandidates(url);
|
url
|
||||||
|
);
|
||||||
return servers?.map((server) => ({ address: server.address })) || [];
|
return servers?.map((server) => ({ address: server.address })) || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,7 +153,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const token = await AsyncStorage.getItem("token");
|
const token = await AsyncStorage.getItem("token");
|
||||||
const serverUrl = await AsyncStorage.getItem("serverUrl");
|
const serverUrl = await AsyncStorage.getItem("serverUrl");
|
||||||
const user = JSON.parse(
|
const user = JSON.parse(
|
||||||
(await AsyncStorage.getItem("user")) as string,
|
(await AsyncStorage.getItem("user")) as string
|
||||||
) as UserDto;
|
) as UserDto;
|
||||||
|
|
||||||
if (serverUrl && token && user.Id && jellyfin) {
|
if (serverUrl && token && user.Id && jellyfin) {
|
||||||
|
|||||||
269
providers/PlaybackProvider.tsx
Normal file
269
providers/PlaybackProvider.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
||||||
|
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
PlaybackInfoResponse,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { OnProgressData, type VideoRef } from "react-native-video";
|
||||||
|
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||||
|
import { getDeviceId } from "@/utils/device";
|
||||||
|
|
||||||
|
type CurrentlyPlayingState = {
|
||||||
|
url: string;
|
||||||
|
item: BaseItemDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlaybackContextType {
|
||||||
|
sessionData: PlaybackInfoResponse | null | undefined;
|
||||||
|
currentlyPlaying: CurrentlyPlayingState | null;
|
||||||
|
videoRef: React.MutableRefObject<VideoRef | null>;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isFullscreen: boolean;
|
||||||
|
progressTicks: number | null;
|
||||||
|
playVideo: () => void;
|
||||||
|
pauseVideo: () => void;
|
||||||
|
stopPlayback: () => void;
|
||||||
|
presentFullscreenPlayer: () => void;
|
||||||
|
dismissFullscreenPlayer: () => void;
|
||||||
|
setIsFullscreen: (isFullscreen: boolean) => void;
|
||||||
|
setIsPlaying: (isPlaying: boolean) => void;
|
||||||
|
onProgress: (data: OnProgressData) => void;
|
||||||
|
setCurrentlyPlayingState: (
|
||||||
|
currentlyPlaying: CurrentlyPlayingState | null
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaybackContext = createContext<PlaybackContextType | null>(null);
|
||||||
|
|
||||||
|
export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const videoRef = useRef<VideoRef | null>(null);
|
||||||
|
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||||
|
const [progressTicks, setProgressTicks] = useState<number | null>(0);
|
||||||
|
const [currentlyPlaying, setCurrentlyPlaying] =
|
||||||
|
useState<CurrentlyPlayingState | null>(null);
|
||||||
|
|
||||||
|
// WS
|
||||||
|
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
const { data: sessionData } = useQuery({
|
||||||
|
queryKey: ["sessionData", currentlyPlaying?.item.Id, user?.Id, api],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!currentlyPlaying?.item.Id) return null;
|
||||||
|
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||||
|
itemId: currentlyPlaying?.item.Id,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
return playbackData.data;
|
||||||
|
},
|
||||||
|
enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: deviceId } = useQuery({
|
||||||
|
queryKey: ["deviceId", api],
|
||||||
|
queryFn: getDeviceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setCurrentlyPlayingState = (state: CurrentlyPlayingState | null) => {
|
||||||
|
if (state) {
|
||||||
|
setCurrentlyPlaying(state);
|
||||||
|
setIsPlaying(true);
|
||||||
|
|
||||||
|
if (settings?.openFullScreenVideoPlayerByDefault)
|
||||||
|
presentFullscreenPlayer();
|
||||||
|
} else {
|
||||||
|
setCurrentlyPlaying(null);
|
||||||
|
setIsFullscreen(false);
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define control methods
|
||||||
|
const playVideo = useCallback(() => {
|
||||||
|
videoRef.current?.resume();
|
||||||
|
setIsPlaying(true);
|
||||||
|
reportPlaybackProgress({
|
||||||
|
api,
|
||||||
|
itemId: currentlyPlaying?.item.Id,
|
||||||
|
positionTicks: progressTicks ? progressTicks : 0,
|
||||||
|
sessionId: sessionData?.PlaySessionId,
|
||||||
|
IsPaused: true,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
currentlyPlaying?.item.Id,
|
||||||
|
sessionData?.PlaySessionId,
|
||||||
|
progressTicks,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pauseVideo = useCallback(() => {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
reportPlaybackProgress({
|
||||||
|
api,
|
||||||
|
itemId: currentlyPlaying?.item.Id,
|
||||||
|
positionTicks: progressTicks ? progressTicks : 0,
|
||||||
|
sessionId: sessionData?.PlaySessionId,
|
||||||
|
IsPaused: false,
|
||||||
|
});
|
||||||
|
}, [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]);
|
||||||
|
|
||||||
|
const stopPlayback = useCallback(async () => {
|
||||||
|
await reportPlaybackStopped({
|
||||||
|
api,
|
||||||
|
itemId: currentlyPlaying?.item?.Id,
|
||||||
|
sessionId: sessionData?.PlaySessionId,
|
||||||
|
positionTicks: progressTicks ? progressTicks : 0,
|
||||||
|
});
|
||||||
|
setCurrentlyPlayingState(null);
|
||||||
|
}, [currentlyPlaying, sessionData, progressTicks]);
|
||||||
|
|
||||||
|
const onProgress = useCallback(
|
||||||
|
({ currentTime }: OnProgressData) => {
|
||||||
|
const ticks = currentTime * 10000000;
|
||||||
|
setProgressTicks(ticks);
|
||||||
|
reportPlaybackProgress({
|
||||||
|
api,
|
||||||
|
itemId: currentlyPlaying?.item.Id,
|
||||||
|
positionTicks: ticks,
|
||||||
|
sessionId: sessionData?.PlaySessionId,
|
||||||
|
IsPaused: !isPlaying,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[sessionData?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying]
|
||||||
|
);
|
||||||
|
|
||||||
|
const presentFullscreenPlayer = useCallback(() => {
|
||||||
|
videoRef.current?.presentFullscreenPlayer();
|
||||||
|
setIsFullscreen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissFullscreenPlayer = useCallback(() => {
|
||||||
|
videoRef.current?.dismissFullscreenPlayer();
|
||||||
|
setIsFullscreen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deviceId || !api) return;
|
||||||
|
|
||||||
|
const url = `wss://${api?.basePath
|
||||||
|
.replace("https://", "")
|
||||||
|
.replace("http://", "")}/socket?api_key=${
|
||||||
|
api?.accessToken
|
||||||
|
}&deviceId=${deviceId}`;
|
||||||
|
|
||||||
|
console.log("WS", url);
|
||||||
|
|
||||||
|
const newWebSocket = new WebSocket(url);
|
||||||
|
|
||||||
|
let keepAliveInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
newWebSocket.onopen = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
// Start sending "KeepAlive" message every 30 seconds
|
||||||
|
keepAliveInterval = setInterval(() => {
|
||||||
|
if (newWebSocket.readyState === WebSocket.OPEN) {
|
||||||
|
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
||||||
|
console.log("KeepAlive message sent");
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
};
|
||||||
|
|
||||||
|
newWebSocket.onerror = (e) => {
|
||||||
|
console.error("WebSocket error:", e);
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
newWebSocket.onclose = (e) => {
|
||||||
|
console.log("WebSocket connection closed:", e.reason);
|
||||||
|
if (keepAliveInterval) {
|
||||||
|
clearInterval(keepAliveInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setWs(newWebSocket);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (keepAliveInterval) {
|
||||||
|
clearInterval(keepAliveInterval);
|
||||||
|
}
|
||||||
|
newWebSocket.close();
|
||||||
|
};
|
||||||
|
}, [api, deviceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ws) return;
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const json = JSON.parse(e.data);
|
||||||
|
const command = json?.Data?.Command;
|
||||||
|
|
||||||
|
// On PlayPause
|
||||||
|
if (command === "PlayPause") {
|
||||||
|
console.log("Command ~ PlayPause");
|
||||||
|
if (isPlaying) pauseVideo();
|
||||||
|
else playVideo();
|
||||||
|
} else if (command === "Stop") {
|
||||||
|
console.log("Command ~ Stop");
|
||||||
|
stopPlayback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [ws, stopPlayback, playVideo, pauseVideo]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlaybackContext.Provider
|
||||||
|
value={{
|
||||||
|
onProgress,
|
||||||
|
progressTicks,
|
||||||
|
setIsPlaying,
|
||||||
|
setIsFullscreen,
|
||||||
|
isFullscreen,
|
||||||
|
isPlaying,
|
||||||
|
currentlyPlaying,
|
||||||
|
sessionData,
|
||||||
|
videoRef,
|
||||||
|
playVideo,
|
||||||
|
setCurrentlyPlayingState,
|
||||||
|
pauseVideo,
|
||||||
|
stopPlayback,
|
||||||
|
presentFullscreenPlayer,
|
||||||
|
dismissFullscreenPlayer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PlaybackContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePlayback = () => {
|
||||||
|
const context = useContext(PlaybackContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("usePlayback must be used within a PlaybackProvider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
10
utils/atoms/playState.ts
Normal file
10
utils/atoms/playState.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
export const playingAtom = atom(false);
|
||||||
|
export const fullScreenAtom = atom(false);
|
||||||
|
export const showCurrentlyPlayingBarAtom = atom(false);
|
||||||
|
export const currentlyPlayingItemAtom = atom<{
|
||||||
|
item: BaseItemDto;
|
||||||
|
playbackUrl: string;
|
||||||
|
} | null>(null);
|
||||||
@@ -10,6 +10,8 @@ type Settings = {
|
|||||||
deviceProfile?: "Expo" | "Native" | "Old";
|
deviceProfile?: "Expo" | "Native" | "Old";
|
||||||
forceDirectPlay?: boolean;
|
forceDirectPlay?: boolean;
|
||||||
mediaListCollectionIds?: string[];
|
mediaListCollectionIds?: string[];
|
||||||
|
searchEngine: "Marlin" | "Jellyfin";
|
||||||
|
marlinServerUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +35,8 @@ const loadSettings = async (): Promise<Settings> => {
|
|||||||
deviceProfile: "Expo",
|
deviceProfile: "Expo",
|
||||||
forceDirectPlay: false,
|
forceDirectPlay: false,
|
||||||
mediaListCollectionIds: [],
|
mediaListCollectionIds: [],
|
||||||
|
searchEngine: "Jellyfin",
|
||||||
|
marlinServerUrl: "",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
19
utils/device.ts
Normal file
19
utils/device.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import uuid from "react-native-uuid";
|
||||||
|
|
||||||
|
export const getOrSetDeviceId = async () => {
|
||||||
|
let deviceId = await AsyncStorage.getItem("deviceId");
|
||||||
|
|
||||||
|
if (!deviceId) {
|
||||||
|
deviceId = uuid.v4() as string;
|
||||||
|
await AsyncStorage.setItem("deviceId", deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deviceId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDeviceId = async () => {
|
||||||
|
let deviceId = await AsyncStorage.getItem("deviceId");
|
||||||
|
|
||||||
|
return deviceId || null;
|
||||||
|
};
|
||||||
@@ -53,7 +53,7 @@ export const getStreamUrl = async ({
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
|
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
|
||||||
@@ -69,7 +69,16 @@ export const getStreamUrl = async ({
|
|||||||
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
||||||
if (item.MediaType === "Video") {
|
if (item.MediaType === "Video") {
|
||||||
console.log("Using direct stream for video!");
|
console.log("Using direct stream for video!");
|
||||||
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
mediaSourceId: itemId,
|
||||||
|
Static: "true",
|
||||||
|
deviceId: api.deviceInfo.id,
|
||||||
|
api_key: api.accessToken,
|
||||||
|
Tag: item.MediaSources?.[0].ETag || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${api.basePath}/Videos/${itemId}/stream.mp4?${params.toString()}`;
|
||||||
} else if (item.MediaType === "Audio") {
|
} else if (item.MediaType === "Audio") {
|
||||||
console.log("Using direct stream for audio!");
|
console.log("Using direct stream for audio!");
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
@@ -87,7 +96,9 @@ export const getStreamUrl = async ({
|
|||||||
EnableRedirection: "true",
|
EnableRedirection: "true",
|
||||||
EnableRemoteMedia: "false",
|
EnableRemoteMedia: "false",
|
||||||
});
|
});
|
||||||
return `${api.basePath}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
return `${
|
||||||
|
api.basePath
|
||||||
|
}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import { AxiosError } from "axios";
|
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
import { postCapabilities } from "../session/capabilities";
|
||||||
|
|
||||||
interface ReportPlaybackProgressParams {
|
interface ReportPlaybackProgressParams {
|
||||||
api: Api;
|
api?: Api | null;
|
||||||
sessionId: string;
|
sessionId?: string | null;
|
||||||
itemId: string;
|
itemId?: string | null;
|
||||||
positionTicks: number;
|
positionTicks?: number | null;
|
||||||
|
IsPaused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,25 +21,44 @@ export const reportPlaybackProgress = async ({
|
|||||||
sessionId,
|
sessionId,
|
||||||
itemId,
|
itemId,
|
||||||
positionTicks,
|
positionTicks,
|
||||||
|
IsPaused = false,
|
||||||
}: ReportPlaybackProgressParams): Promise<void> => {
|
}: ReportPlaybackProgressParams): Promise<void> => {
|
||||||
console.info(
|
if (!api || !sessionId || !itemId || !positionTicks) {
|
||||||
"Reporting playback progress:",
|
console.error(
|
||||||
sessionId,
|
"Missing required parameter",
|
||||||
itemId,
|
sessionId,
|
||||||
positionTicks,
|
itemId,
|
||||||
);
|
positionTicks
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("reportPlaybackProgress ~ IsPaused", IsPaused);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await postCapabilities({
|
||||||
|
api,
|
||||||
|
itemId,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to post capabilities.", error);
|
||||||
|
throw new Error("Failed to post capabilities.");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.axiosInstance.post(
|
await api.axiosInstance.post(
|
||||||
`${api.basePath}/Sessions/Playing/Progress`,
|
`${api.basePath}/Sessions/Playing/Progress`,
|
||||||
{
|
{
|
||||||
ItemId: itemId,
|
ItemId: itemId,
|
||||||
PlaySessionId: sessionId,
|
PlaySessionId: sessionId,
|
||||||
IsPaused: false,
|
IsPaused,
|
||||||
PositionTicks: Math.round(positionTicks),
|
PositionTicks: Math.round(positionTicks),
|
||||||
CanSeek: true,
|
CanSeek: true,
|
||||||
MediaSourceId: itemId,
|
MediaSourceId: itemId,
|
||||||
|
EventName: "timeupdate",
|
||||||
},
|
},
|
||||||
{ headers: getAuthHeaders(api) },
|
{ headers: getAuthHeaders(api) }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -41,12 +41,15 @@ export const reportPlaybackStopped = async ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("reportPlaybackStopped ~", { sessionId, itemId });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${api.basePath}/PlayingItems/${itemId}`;
|
const url = `${api.basePath}/PlayingItems/${itemId}`;
|
||||||
const params = {
|
const params = {
|
||||||
playSessionId: sessionId,
|
playSessionId: sessionId,
|
||||||
positionTicks: Math.round(positionTicks),
|
positionTicks: Math.round(positionTicks),
|
||||||
mediaSourceId: itemId,
|
MediaSourceId: itemId,
|
||||||
|
IsPaused: true,
|
||||||
};
|
};
|
||||||
const headers = getAuthHeaders(api);
|
const headers = getAuthHeaders(api);
|
||||||
|
|
||||||
@@ -58,7 +61,7 @@ export const reportPlaybackStopped = async ({
|
|||||||
console.error(
|
console.error(
|
||||||
"Failed to report playback progress",
|
"Failed to report playback progress",
|
||||||
error.message,
|
error.message,
|
||||||
error.response?.data,
|
error.response?.data
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.error("Failed to report playback progress", error);
|
console.error("Failed to report playback progress", error);
|
||||||
|
|||||||
48
utils/jellyfin/session/capabilities.ts
Normal file
48
utils/jellyfin/session/capabilities.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import {
|
||||||
|
SessionApi,
|
||||||
|
SessionApiPostCapabilitiesRequest,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/api/session-api";
|
||||||
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
|
interface PostCapabilitiesParams {
|
||||||
|
api: Api | null | undefined;
|
||||||
|
itemId: string | null | undefined;
|
||||||
|
sessionId: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a media item as not played for a specific user.
|
||||||
|
*
|
||||||
|
* @param params - The parameters for marking an item as not played
|
||||||
|
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
||||||
|
*/
|
||||||
|
export const postCapabilities = async ({
|
||||||
|
api,
|
||||||
|
itemId,
|
||||||
|
sessionId,
|
||||||
|
}: PostCapabilitiesParams): Promise<void> => {
|
||||||
|
if (!api || !itemId || !sessionId) {
|
||||||
|
throw new Error("Missing required parameters");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await api.axiosInstance.post(
|
||||||
|
api.basePath + "/Sessions/Capabilities/Full",
|
||||||
|
{
|
||||||
|
playableMediaTypes: ["Audio", "Video", "Audio"],
|
||||||
|
supportedCommands: ["PlayState", "Play"],
|
||||||
|
supportsMediaControl: true,
|
||||||
|
id: sessionId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error: any | AxiosError) {
|
||||||
|
console.log("Failed to mark as not played", error);
|
||||||
|
throw new Error("Failed to mark as not played");
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user