mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
71 Commits
v0.6.1
...
feat/on-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceb9969007 | ||
|
|
d330dd8db4 | ||
|
|
20739e6e2c | ||
|
|
ec50a90a32 | ||
|
|
6f6b46c14a | ||
|
|
7fcdfe9452 | ||
|
|
f9af493dc8 | ||
|
|
e8dc9e759a | ||
|
|
06877f4339 | ||
|
|
c496b1036b | ||
|
|
4cca6f0e8c | ||
|
|
7bf5fb9a01 | ||
|
|
bbf926e752 | ||
|
|
9b2a0487d2 | ||
|
|
a73488614c | ||
|
|
03fdf31b4b | ||
|
|
2e563bcbe0 | ||
|
|
b1062628d9 | ||
|
|
e216c8392f | ||
|
|
1a47ade4dc | ||
|
|
a54da1c3dc | ||
|
|
874364fcde | ||
|
|
9059f33538 | ||
|
|
27785e7d18 | ||
|
|
9a621cab4e | ||
|
|
a37ac74e9f | ||
|
|
4d4bb0f6a4 | ||
|
|
6bd60b2ec6 | ||
|
|
696a2a4780 | ||
|
|
13ac9b0443 | ||
|
|
83407674be | ||
|
|
53bb1751c2 | ||
|
|
fa31ff8f2b | ||
|
|
f863c95f70 | ||
|
|
f227565dbf | ||
|
|
7abc629a10 | ||
|
|
70a250df5b | ||
|
|
dbdf56b990 | ||
|
|
8b2204896a | ||
|
|
89729a95cd | ||
|
|
57dd3b8446 | ||
|
|
8d2a0378ca | ||
|
|
cbe01a0012 | ||
|
|
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
*.apk
|
||||
*.ipa
|
||||
.continuerc.json
|
||||
|
||||
50
README.md
50
README.md
@@ -26,22 +26,46 @@ 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.
|
||||
|
||||
### Chromecast
|
||||
|
||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
|
||||
|
||||
## Plugins
|
||||
|
||||
In Streamyfin we have build in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality.
|
||||
|
||||
### Collection rows
|
||||
|
||||
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.
|
||||
|
||||
### Jellysearch
|
||||
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
||||
|
||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||
|
||||
## 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.
|
||||
|
||||
## Get it now
|
||||
|
||||
<div style="display:flex;">
|
||||
<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>
|
||||
|
||||
<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 style="display: flex; gap: 5px;">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
Or download the APKs [here on GitHub](https://github.com/fredrikburmester/streamyfin/releases) for Android.
|
||||
|
||||
### Beta testing
|
||||
|
||||
Get the latest updates by using the TestFlight version of the app.
|
||||
@@ -50,8 +74,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"/>
|
||||
</a>
|
||||
|
||||
Or download the APKs here on GitHub for Android.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
@@ -65,6 +87,12 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
||||
|
||||
### Development info
|
||||
|
||||
1. Use node `20`
|
||||
2. Install deps `bun i`
|
||||
3. `Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
|
||||
|
||||
## Extended chromecast controls
|
||||
|
||||
Add this to AppDelegate.mm:
|
||||
|
||||
```
|
||||
@@ -106,7 +134,7 @@ Key points of the MPL-2.0:
|
||||
|
||||
## 🌐 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:
|
||||
|
||||
|
||||
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.6.1",
|
||||
"version": "0.8.2",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 17,
|
||||
"versionCode": 23,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon.png"
|
||||
},
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="library"
|
||||
name="libraries"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "Library",
|
||||
|
||||
@@ -72,6 +72,7 @@ export default function index() {
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount"],
|
||||
limit: 20,
|
||||
})
|
||||
).data.Items) ||
|
||||
[],
|
||||
@@ -171,30 +172,19 @@ export default function index() {
|
||||
});
|
||||
|
||||
const { data: mediaListCollections } = useQuery({
|
||||
queryKey: [
|
||||
"mediaListCollections-home",
|
||||
user?.Id,
|
||||
settings?.mediaListCollectionIds,
|
||||
],
|
||||
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
tags: ["medialist", "promoted"],
|
||||
tags: ["sf_promoted"],
|
||||
recursive: true,
|
||||
fields: ["Tags"],
|
||||
includeItemTypes: ["BoxSet"],
|
||||
});
|
||||
|
||||
const ids =
|
||||
response.data.Items?.filter(
|
||||
(c) =>
|
||||
c.Name !== "cf_carousel" &&
|
||||
settings?.mediaListCollectionIds?.includes(c.Id!)
|
||||
) ?? [];
|
||||
|
||||
return ids;
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||
staleTime: 0,
|
||||
@@ -208,7 +198,10 @@ export default function index() {
|
||||
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ["mediaListCollections-home"],
|
||||
queryKey: ["sf_promoted"],
|
||||
});
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ["sf_carousel"],
|
||||
});
|
||||
setLoading(false);
|
||||
}, [queryClient, user?.Id]);
|
||||
|
||||
434
app/(auth)/(tabs)/libraries/[libraryId].tsx
Normal file
434
app/(auth)/(tabs)/libraries/[libraryId].tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
|
||||
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 MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
sortByAtom,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
sortOrderOptions,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
getItemsApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
|
||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { libraryId } = searchParams as { libraryId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
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);
|
||||
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setSortBy([
|
||||
{
|
||||
key: "SortName",
|
||||
value: "Name",
|
||||
},
|
||||
]);
|
||||
setSortOrder([
|
||||
{
|
||||
key: "Ascending",
|
||||
value: "Ascending",
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||
(event) => {
|
||||
setOrientation(event.orientationInfo.orientation);
|
||||
}
|
||||
);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||
setOrientation(initialOrientation);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { data: library } = useQuery({
|
||||
queryKey: ["library", libraryId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getUserLibraryApi(api).getItem({
|
||||
itemId: libraryId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!libraryId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !library) return null;
|
||||
|
||||
let includeItemTypes: BaseItemKind[] | undefined = [];
|
||||
|
||||
switch (library?.CollectionType) {
|
||||
case "movies":
|
||||
includeItemTypes.push("Movie");
|
||||
break;
|
||||
case "boxsets":
|
||||
includeItemTypes.push("BoxSet");
|
||||
break;
|
||||
case "tvshows":
|
||||
includeItemTypes.push("Series");
|
||||
break;
|
||||
case "music":
|
||||
includeItemTypes.push("MusicAlbum");
|
||||
break;
|
||||
default:
|
||||
includeItemTypes = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
limit: 20,
|
||||
startIndex: pageParam,
|
||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0].key],
|
||||
includeItemTypes,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
recursive: true,
|
||||
imageTypeLimit: 1,
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
libraryId,
|
||||
library,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"library-items",
|
||||
libraryId,
|
||||
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 && !!library,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
return (
|
||||
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
||||
[]
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<MemoizedTouchableItemRouter
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom:
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
|
||||
}}
|
||||
item={item}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf:
|
||||
index % 3 === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % 3 === 0
|
||||
? "flex-start"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</MemoizedTouchableItemRouter>
|
||||
),
|
||||
[orientation]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className="">
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="genreFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title="Genres"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="yearFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title="Years"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="tagsFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title="Tags"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="sortBy"
|
||||
queryFn={async () => sortOptions}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title="Sort By"
|
||||
renderItemLabel={(item) => item.value}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="sortOrder"
|
||||
queryFn={async () => sortOrderOptions}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title="Sort Order"
|
||||
renderItemLabel={(item) => item.value}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[
|
||||
libraryId,
|
||||
api,
|
||||
user?.Id,
|
||||
selectedGenres,
|
||||
setSelectedGenres,
|
||||
selectedYears,
|
||||
setSelectedYears,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
]
|
||||
);
|
||||
|
||||
if (!library) return null;
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
ListEmptyComponent={
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
||||
</View>
|
||||
}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={255}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{ paddingBottom: 24 }}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Page);
|
||||
@@ -16,7 +16,7 @@ export default function IndexLayout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="collections/[collectionId]"
|
||||
name="[libraryId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
@@ -49,7 +49,7 @@ export default function index() {
|
||||
paddingBottom: 150,
|
||||
}}
|
||||
data={data}
|
||||
renderItem={({ item }) => <CollectionCard collection={item} />}
|
||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
ItemSeparatorComponent={() => <View className="h-4" />}
|
||||
estimatedItemSize={200}
|
||||
@@ -58,10 +58,10 @@ export default function index() {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
collection: BaseItemDto;
|
||||
library: BaseItemDto;
|
||||
}
|
||||
|
||||
const CollectionCard: React.FC<Props> = ({ collection }) => {
|
||||
const LibraryItemCard: React.FC<Props> = ({ library }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -70,9 +70,9 @@ const CollectionCard: React.FC<Props> = ({ collection }) => {
|
||||
() =>
|
||||
getPrimaryImageUrl({
|
||||
api,
|
||||
item: collection,
|
||||
item: library,
|
||||
}),
|
||||
[collection]
|
||||
[library]
|
||||
);
|
||||
|
||||
if (!url) return null;
|
||||
@@ -80,7 +80,7 @@ const CollectionCard: React.FC<Props> = ({ collection }) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
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 ">
|
||||
@@ -96,7 +96,7 @@ const CollectionCard: React.FC<Props> = ({ collection }) => {
|
||||
}}
|
||||
/>
|
||||
<Text className="font-bold text-xl text-start px-4">
|
||||
{collection.Name}
|
||||
{library.Name}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
@@ -1,335 +0,0 @@
|
||||
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 MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
sortByAtom,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
sortOrderOptions,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getFilterApi, getItemsApi } 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 React, { useCallback, useEffect, useMemo } from "react";
|
||||
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 searchParams = useLocalSearchParams();
|
||||
const { collectionId } = searchParams as { collectionId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
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);
|
||||
|
||||
const { data: collection } = useQuery({
|
||||
queryKey: ["collection", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
ids: [collectionId],
|
||||
});
|
||||
const data = response.data.Items?.[0];
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!collectionId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !collection) return null;
|
||||
|
||||
const includeItemTypes: BaseItemKind[] = [];
|
||||
|
||||
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({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
limit: 66,
|
||||
startIndex: pageParam,
|
||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0].key],
|
||||
includeItemTypes,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
recursive: true,
|
||||
imageTypeLimit: 1,
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
collectionId,
|
||||
collection?.CollectionType,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"library-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 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 || !collection.CollectionType) return null;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
onScroll={({ nativeEvent }) => {
|
||||
if (isCloseToBottom(nativeEvent)) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
scrollEventThrottle={400}
|
||||
>
|
||||
<View className="mt-4 mb-24">
|
||||
<View className="mb-4">
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<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}
|
||||
values={selectedGenres}
|
||||
title="Genres"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
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}
|
||||
values={selectedTags}
|
||||
title="Tags"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
@@ -9,13 +10,32 @@ import AlbumCover from "@/components/posters/AlbumCover";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
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 { router, useNavigation } from "expo-router";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Href,
|
||||
router,
|
||||
useLocalSearchParams,
|
||||
useNavigation,
|
||||
usePathname,
|
||||
} from "expo-router";
|
||||
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 { useDebounce } from "use-debounce";
|
||||
|
||||
@@ -29,6 +49,10 @@ const exampleSearches = [
|
||||
];
|
||||
|
||||
export default function search() {
|
||||
const params = useLocalSearchParams();
|
||||
|
||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
@@ -36,107 +60,151 @@ export default function search() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
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();
|
||||
useLayoutEffect(() => {
|
||||
if (Platform.OS === "ios")
|
||||
navigation.setOptions({
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search...",
|
||||
onChangeText: (e: any) => setSearch(e.nativeEvent.text),
|
||||
onChangeText: (e: any) => {
|
||||
router.setParams({ q: "" });
|
||||
setSearch(e.nativeEvent.text);
|
||||
},
|
||||
hideWhenScrolling: false,
|
||||
autoFocus: true,
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const { data: movies, isLoading: l1 } = useQuery({
|
||||
queryKey: ["search-movies", debouncedSearch],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: debouncedSearch,
|
||||
limit: 10,
|
||||
includeItemTypes: ["Movie"],
|
||||
});
|
||||
|
||||
return searchApi.data.SearchHints;
|
||||
},
|
||||
const { data: movies, isFetching: l1 } = useQuery({
|
||||
queryKey: ["search", "movies", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Movie"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: series, isLoading: l2 } = useQuery({
|
||||
queryKey: ["search-series", debouncedSearch],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: debouncedSearch,
|
||||
limit: 10,
|
||||
includeItemTypes: ["Series"],
|
||||
});
|
||||
|
||||
return searchApi.data.SearchHints;
|
||||
},
|
||||
const { data: series, isFetching: l2 } = useQuery({
|
||||
queryKey: ["search", "series", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Series"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: episodes, isLoading: l3 } = useQuery({
|
||||
queryKey: ["search-episodes", debouncedSearch],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: debouncedSearch,
|
||||
limit: 10,
|
||||
includeItemTypes: ["Episode"],
|
||||
});
|
||||
|
||||
return searchApi.data.SearchHints;
|
||||
},
|
||||
const { data: episodes, isFetching: l3 } = useQuery({
|
||||
queryKey: ["search", "episodes", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Episode"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: artists, isLoading: l4 } = useQuery({
|
||||
queryKey: ["search-artists", debouncedSearch],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: debouncedSearch,
|
||||
limit: 10,
|
||||
includeItemTypes: ["MusicArtist"],
|
||||
});
|
||||
|
||||
return searchApi.data.SearchHints;
|
||||
},
|
||||
const { data: collections, isFetching: l7 } = useQuery({
|
||||
queryKey: ["search", "collections", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["BoxSet"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: albums, isLoading: l5 } = useQuery({
|
||||
queryKey: ["search-albums", debouncedSearch],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: debouncedSearch,
|
||||
limit: 10,
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
});
|
||||
|
||||
return searchApi.data.SearchHints;
|
||||
},
|
||||
const { data: actors, isFetching: l8 } = useQuery({
|
||||
queryKey: ["search", "actors", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Person"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: songs, isLoading: l6 } = useQuery({
|
||||
queryKey: ["search-songs", debouncedSearch],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||
const { data: artists, isFetching: l4 } = useQuery({
|
||||
queryKey: ["search", "artists", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["MusicArtist"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: debouncedSearch,
|
||||
limit: 10,
|
||||
includeItemTypes: ["Audio"],
|
||||
});
|
||||
const { data: albums, isFetching: l5 } = useQuery({
|
||||
queryKey: ["search", "albums", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["MusicAlbum"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
return searchApi.data.SearchHints;
|
||||
},
|
||||
const { data: songs, isFetching: l6 } = useQuery({
|
||||
queryKey: ["search", "songs", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Audio"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const noResults = useMemo(() => {
|
||||
@@ -146,13 +214,15 @@ export default function search() {
|
||||
songs?.length ||
|
||||
movies?.length ||
|
||||
episodes?.length ||
|
||||
series?.length
|
||||
series?.length ||
|
||||
collections?.length ||
|
||||
actors?.length
|
||||
);
|
||||
}, [artists, episodes, albums, songs, movies, series]);
|
||||
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return l1 || l2 || l3 || l4 || l5 || l6;
|
||||
}, [l1, l2, l3, l4, l5, l6]);
|
||||
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
|
||||
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -173,6 +243,13 @@ export default function search() {
|
||||
/>
|
||||
</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
|
||||
header="Movies"
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
@@ -182,7 +259,7 @@ export default function search() {
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
className="flex flex-col w-32"
|
||||
className="flex flex-col w-28"
|
||||
onPress={() => router.push(`/items/${item.Id}`)}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
@@ -207,7 +284,7 @@ export default function search() {
|
||||
<TouchableOpacity
|
||||
key={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} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
@@ -231,7 +308,7 @@ export default function search() {
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/items/${item.Id}`)}
|
||||
className="flex flex-col w-48"
|
||||
className="flex flex-col w-44"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
@@ -240,6 +317,46 @@ export default function search() {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={collections?.map((m) => m.Id!)}
|
||||
header="Collections"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
onPress={() => router.push(`/collections/${item.Id}`)}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={actors?.map((m) => m.Id!)}
|
||||
header="Actors"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={artists?.map((m) => m.Id!)}
|
||||
header="Artists"
|
||||
@@ -250,7 +367,7 @@ export default function search() {
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-32"
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<AlbumCover id={item.Id} />
|
||||
<ItemCardText item={item} />
|
||||
@@ -269,7 +386,7 @@ export default function search() {
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-32"
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<AlbumCover id={item.Id} />
|
||||
<ItemCardText item={item} />
|
||||
@@ -288,7 +405,7 @@ export default function search() {
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-32"
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<AlbumCover id={item.AlbumId} />
|
||||
<ItemCardText item={item} />
|
||||
|
||||
151
app/(auth)/actors/[actorId].tsx
Normal file
151
app/(auth)/actors/[actorId].tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Bitrate } from "@/components/BitrateSelector";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { Ratings } from "@/components/Ratings";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import native from "@/utils/profiles/native";
|
||||
import old from "@/utils/profiles/old";
|
||||
import { getItemsApi, getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useCastDevice } from "react-native-google-cast";
|
||||
import { ParallaxScrollView } from "../../../components/ParallaxPage";
|
||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
const { actorId } = local as { actorId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: item, isLoading: l1 } = useQuery({
|
||||
queryKey: ["item", actorId],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: actorId,
|
||||
}),
|
||||
enabled: !!actorId && !!api,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !user?.Id) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
personIds: [actorId],
|
||||
startIndex: pageParam,
|
||||
limit: 8,
|
||||
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||
includeItemTypes: ["Movie", "Series"],
|
||||
recursive: true,
|
||||
fields: [
|
||||
"ParentId",
|
||||
"PrimaryImageAspectRatio",
|
||||
"ParentId",
|
||||
"PrimaryImageAspectRatio",
|
||||
],
|
||||
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
|
||||
collapseBoxSetItems: false,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
[api, user?.Id, actorId]
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item]
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!item?.Id || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerImage={
|
||||
<Image
|
||||
source={{
|
||||
uri: backdropUrl,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col space-y-4 my-4">
|
||||
<View className="px-4 mb-4">
|
||||
<MoviesTitleHeader item={item} className="mb-4" />
|
||||
<OverviewText text={item.Overview} />
|
||||
</View>
|
||||
|
||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||
Appeared In
|
||||
</Text>
|
||||
<InfiniteHorizontalScroll
|
||||
height={247}
|
||||
renderItem={(i, idx) => (
|
||||
<TouchableItemRouter
|
||||
key={idx}
|
||||
item={i}
|
||||
className={`flex flex-col
|
||||
${"w-28"}
|
||||
`}
|
||||
>
|
||||
<View>
|
||||
<MoviePoster item={i} />
|
||||
<ItemCardText item={i} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
queryFn={fetchItems}
|
||||
queryKey={["actor", "movies", actorId]}
|
||||
/>
|
||||
<View className="h-12"></View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -84,7 +84,7 @@ export default function page() {
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: albums?.Items?.[0].AlbumArtist,
|
||||
title: albums?.Items?.[0]?.AlbumArtist || "",
|
||||
});
|
||||
}, [albums]);
|
||||
|
||||
|
||||
@@ -1,20 +1,45 @@
|
||||
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 ArtistPoster from "@/components/posters/ArtistPoster";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
sortByAtom,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
sortOrderOptions,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
ItemSortBy,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import {
|
||||
getFilterApi,
|
||||
getItemsApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { FlatList, NativeScrollEvent, ScrollView, View } from "react-native";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
|
||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -22,201 +47,355 @@ const page: React.FC = () => {
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setSortBy([
|
||||
{
|
||||
key: "PremiereDate",
|
||||
value: "Premiere Date",
|
||||
},
|
||||
]);
|
||||
setSortOrder([
|
||||
{
|
||||
key: "Ascending",
|
||||
value: "Ascending",
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const { data: collection } = useQuery({
|
||||
queryKey: ["collection", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getItemsApi(api).getItems({
|
||||
const response = await getUserLibraryApi(api).getItem({
|
||||
itemId: collectionId,
|
||||
userId: user?.Id,
|
||||
ids: [collectionId],
|
||||
});
|
||||
const data = response.data.Items?.[0];
|
||||
const data = response.data;
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!collectionId,
|
||||
staleTime: 0,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const [startIndex, setStartIndex] = useState<number>(0);
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: collection?.Name || "" });
|
||||
}, [navigation, collection]);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<{
|
||||
Items: BaseItemDto[];
|
||||
TotalRecordCount: number;
|
||||
}>({
|
||||
queryKey: ["collection-items", collection?.Id, startIndex],
|
||||
queryFn: async () => {
|
||||
if (!api || !collectionId)
|
||||
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 fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !collection) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
limit: 100,
|
||||
startIndex,
|
||||
sortBy,
|
||||
sortOrder: ["Ascending"],
|
||||
includeItemTypes,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
recursive: true,
|
||||
imageTypeLimit: 1,
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
limit: 18,
|
||||
startIndex: pageParam,
|
||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0].key],
|
||||
fields: [
|
||||
"ItemCounts",
|
||||
"PrimaryImageAspectRatio",
|
||||
"CanDelete",
|
||||
"MediaSourceCount",
|
||||
],
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
});
|
||||
|
||||
const data = response.data.Items;
|
||||
|
||||
return {
|
||||
Items: data || [],
|
||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
||||
};
|
||||
return response.data || null;
|
||||
},
|
||||
enabled: !!collection?.Id && !!api && !!user?.Id,
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
collection,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = 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(() => {
|
||||
return data?.TotalRecordCount;
|
||||
const flatData = useMemo(() => {
|
||||
return (
|
||||
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
||||
[]
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<View>
|
||||
<View className="px-4 mb-4">
|
||||
<Text className="font-bold text-3xl mb-2">{collection?.Name}</Text>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{startIndex + 1}-{Math.min(startIndex + 100, totalItems || 0)} of{" "}
|
||||
{totalItems}
|
||||
</Text>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<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>
|
||||
</View>
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<MemoizedTouchableItemRouter
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom:
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
|
||||
}}
|
||||
item={item}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf:
|
||||
index % 3 === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % 3 === 0
|
||||
? "flex-start"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</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}`);
|
||||
</MemoizedTouchableItemRouter>
|
||||
),
|
||||
[orientation]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className="">
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="genreFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title="Genres"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="yearFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title="Years"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="tagsFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title="Tags"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="sortBy"
|
||||
queryFn={async () => sortOptions}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title="Sort By"
|
||||
renderItemLabel={(item) => item.value}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="sortOrder"
|
||||
queryFn={async () => sortOrderOptions}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title="Sort Order"
|
||||
renderItemLabel={(item) => item.value}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
</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>
|
||||
),
|
||||
[
|
||||
collectionId,
|
||||
api,
|
||||
user?.Id,
|
||||
selectedGenres,
|
||||
setSelectedGenres,
|
||||
selectedYears,
|
||||
setSelectedYears,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
]
|
||||
);
|
||||
|
||||
if (!collection) return null;
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
ListEmptyComponent={
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
||||
</View>
|
||||
}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={255}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{ paddingBottom: 24 }}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
</ScrollView>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
fullScreenAtom,
|
||||
playingAtom,
|
||||
} from "@/components/CurrentlyPlayingBar";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
@@ -34,13 +29,9 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { useCastDevice } from "react-native-google-cast";
|
||||
import { ParallaxScrollView } from "../../../components/ParallaxPage";
|
||||
|
||||
const page: React.FC = () => {
|
||||
@@ -54,12 +45,6 @@ const page: React.FC = () => {
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
|
||||
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
|
||||
const [, setPlaying] = useAtom(playingAtom);
|
||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
||||
|
||||
const client = useRemoteMediaClient();
|
||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
@@ -139,43 +124,6 @@ const page: React.FC = () => {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const onPressPlay = useCallback(
|
||||
async (type: "device" | "cast" = "device") => {
|
||||
if (!playbackUrl || !item) return;
|
||||
|
||||
if (type === "cast" && client) {
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: playbackUrl,
|
||||
contentType: "video/mp4",
|
||||
metadata: {
|
||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCurrentlyPlying({
|
||||
item,
|
||||
playbackUrl,
|
||||
});
|
||||
setPlaying(true);
|
||||
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
||||
setFullscreen(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[playbackUrl, item, settings]
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
@@ -246,7 +194,7 @@ const page: React.FC = () => {
|
||||
|
||||
<View className="flex flex-row justify-between items-center mb-2">
|
||||
{playbackUrl ? (
|
||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
||||
<DownloadItem item={item} />
|
||||
) : (
|
||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
||||
)}
|
||||
@@ -274,12 +222,7 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
<View className="flex flex-row items-center justify-between w-full">
|
||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||
<PlayButton
|
||||
item={item}
|
||||
chromecastReady={chromecastReady}
|
||||
onPress={onPressPlay}
|
||||
className="grow"
|
||||
/>
|
||||
<PlayButton item={item} url={playbackUrl} className="grow" />
|
||||
<NextEpisodeButton item={item} className="ml-2" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -43,7 +43,7 @@ const page: React.FC = () => {
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
@@ -52,7 +52,7 @@ const page: React.FC = () => {
|
||||
api,
|
||||
item,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
if (!item || !backdropUrl) return null;
|
||||
@@ -87,7 +87,7 @@ const page: React.FC = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4 pb-24">
|
||||
<View className="flex flex-col pt-4">
|
||||
<View className="px-4 py-4">
|
||||
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
||||
<Text className="">{item?.Overview}</Text>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ScrollView, View } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||
import { WebSocketsTest } from "@/components/settings/WebsocketsText";
|
||||
|
||||
export default function settings() {
|
||||
const { logout } = useJellyfin();
|
||||
@@ -44,7 +45,7 @@ export default function settings() {
|
||||
onPress={async () => {
|
||||
await deleteAllFiles();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success,
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -55,7 +56,7 @@ export default function settings() {
|
||||
onPress={async () => {
|
||||
await clearLogs();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success,
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,10 +2,6 @@ import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
playingAtom,
|
||||
} from "@/components/CurrentlyPlayingBar";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
@@ -15,6 +11,7 @@ import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
@@ -41,7 +38,7 @@ const page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [, setPlaying] = useAtom(playingAtom);
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
const navigation = useNavigation();
|
||||
@@ -140,7 +137,6 @@ const page: React.FC = () => {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
const client = useRemoteMediaClient();
|
||||
|
||||
const onPressPlay = useCallback(
|
||||
@@ -167,11 +163,10 @@ const page: React.FC = () => {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCp({
|
||||
setCurrentlyPlayingState({
|
||||
item,
|
||||
playbackUrl,
|
||||
url: playbackUrl,
|
||||
});
|
||||
setPlaying(true);
|
||||
}
|
||||
},
|
||||
[playbackUrl, item]
|
||||
@@ -224,7 +219,7 @@ const page: React.FC = () => {
|
||||
|
||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
||||
{playbackUrl ? (
|
||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
||||
<DownloadItem item={item} />
|
||||
) : (
|
||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
||||
)}
|
||||
@@ -249,12 +244,7 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
<View className="flex flex-row items-center justify-between w-full">
|
||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||
<PlayButton
|
||||
item={item}
|
||||
chromecastReady={chromecastReady}
|
||||
onPress={onPressPlay}
|
||||
className="grow"
|
||||
/>
|
||||
<PlayButton item={item} className="grow" />
|
||||
<NextEpisodeButton item={item} className="ml-2" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
221
app/_layout.tsx
221
app/_layout.tsx
@@ -1,22 +1,22 @@
|
||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
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 { 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 { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import "react-native-reanimated";
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
@@ -82,99 +82,108 @@ function Layout() {
|
||||
<ActionSheetProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<JellyfinProvider>
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/settings"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Settings",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/downloads"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Downloads",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/items/[id]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/artists/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/artists/[artistId]/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/albums/[albumId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/songs/[songId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/series/[id]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<CurrentlyPlayingBar />
|
||||
</ThemeProvider>
|
||||
<PlaybackProvider>
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/settings"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Settings",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/downloads"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Downloads",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/items/[id]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/actors/[actorId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/artists/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/artists/[artistId]/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/albums/[albumId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/songs/[songId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/series/[id]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<CurrentlyPlayingBar />
|
||||
</ThemeProvider>
|
||||
</PlaybackProvider>
|
||||
</JellyfinProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</ActionSheetProvider>
|
||||
|
||||
@@ -44,8 +44,11 @@ const Login: React.FC = () => {
|
||||
await login(credentials.username, credentials.password);
|
||||
}
|
||||
} catch (error) {
|
||||
const e = error as AxiosError;
|
||||
setError(e.message);
|
||||
if (error instanceof Error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setError("An unexpected error occurred");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
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 |
@@ -22,12 +22,12 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
const audioStreams = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const selectedAudioSteam = useMemo(
|
||||
() => audioStreams?.find((x) => x.Index === selected),
|
||||
[audioStreams, selected],
|
||||
[audioStreams, selected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -42,7 +42,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="">
|
||||
{tc(selectedAudioSteam?.DisplayTitle, 13)}
|
||||
</Text>
|
||||
|
||||
@@ -52,7 +52,7 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{BITRATES.find((b) => b.value === selected.value)?.key}
|
||||
</Text>
|
||||
|
||||
@@ -9,10 +9,12 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
item: BaseItemDto;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
item,
|
||||
width = 176,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
@@ -21,23 +23,33 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 70,
|
||||
quality: 80,
|
||||
width: 300,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const [progress, setProgress] = useState(
|
||||
item.UserData?.PlayedPercentage || 0,
|
||||
item.UserData?.PlayedPercentage || 0
|
||||
);
|
||||
|
||||
if (!url)
|
||||
return (
|
||||
<View className="w-48 aspect-video border border-neutral-800"></View>
|
||||
<View
|
||||
className="aspect-video border border-neutral-800"
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
></View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="w-48 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||
<View
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"
|
||||
>
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
|
||||
@@ -1,50 +1,47 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
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 { 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 { useRouter, useSegments } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
import Video from "react-native-video";
|
||||
import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
export const currentlyPlayingItemAtom = atom<{
|
||||
item: BaseItemDto;
|
||||
playbackUrl: string;
|
||||
} | null>(null);
|
||||
|
||||
export const playingAtom = atom(false);
|
||||
export const fullScreenAtom = atom(false);
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import {
|
||||
FFmpegKit,
|
||||
FFmpegKitConfig,
|
||||
FFmpegSession,
|
||||
ReturnCode,
|
||||
} from "ffmpeg-kit-react-native";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
export const CurrentlyPlayingBar: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const segments = useSegments();
|
||||
const {
|
||||
currentlyPlaying,
|
||||
pauseVideo,
|
||||
playVideo,
|
||||
setCurrentlyPlayingState,
|
||||
stopPlayback,
|
||||
setIsPlaying,
|
||||
isPlaying,
|
||||
videoRef,
|
||||
onProgress,
|
||||
} = usePlayback();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
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 aPadding = useSharedValue(0);
|
||||
@@ -74,6 +71,106 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
};
|
||||
});
|
||||
|
||||
const [streamUrl, setStreamUrl] = useState<string | null>(null);
|
||||
const [ffmpegSession, setFfmpegSession] = useState<FFmpegSession | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const startStreamingTranscode = async (inputUrl: string) => {
|
||||
const outputDir = `${FileSystem.cacheDirectory}stream_${Date.now()}`;
|
||||
const manifestPath = `${outputDir}/stream.m3u8`;
|
||||
|
||||
// Ensure the output directory exists
|
||||
await FileSystem.makeDirectoryAsync(outputDir, { intermediates: true });
|
||||
|
||||
// Base FFmpeg command
|
||||
let ffmpegCommand = `-i "${inputUrl}" `;
|
||||
|
||||
// Add hardware acceleration based on platform
|
||||
if (Platform.OS === "android") {
|
||||
ffmpegCommand += "-c:v h264_mediacodec "; // Hardware acceleration for Android
|
||||
} else if (Platform.OS === "ios") {
|
||||
ffmpegCommand += "-c:v h264_videotoolbox "; // Hardware acceleration for iOS
|
||||
} else {
|
||||
ffmpegCommand += "-c:v libx264 "; // Fallback to software encoding
|
||||
}
|
||||
|
||||
// Complete the command
|
||||
ffmpegCommand += `-c:a aac -f hls -hls_time 4 -hls_list_size 5 -hls_flags delete_segments "${manifestPath}"`;
|
||||
|
||||
console.log("FFmpeg command:", ffmpegCommand);
|
||||
|
||||
// Start FFmpeg process and return the session
|
||||
return FFmpegKit.executeAsync(ffmpegCommand);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const prepareStream = async () => {
|
||||
if (currentlyPlaying?.url) {
|
||||
try {
|
||||
// Check if we already have a stream for this URL
|
||||
const existingStream = await AsyncStorage.getItem(
|
||||
currentlyPlaying.url
|
||||
);
|
||||
if (existingStream) {
|
||||
setStreamUrl(existingStream);
|
||||
} else {
|
||||
const session = await startStreamingTranscode(currentlyPlaying.url);
|
||||
setFfmpegSession(session);
|
||||
|
||||
const returnCode = await session.getReturnCode();
|
||||
|
||||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
console.log("Transcoding completed successfully");
|
||||
const outputDir = `${
|
||||
FileSystem.cacheDirectory
|
||||
}stream_${Date.now()}`;
|
||||
const manifestPath = `${outputDir}/stream.m3u8`;
|
||||
setStreamUrl(manifestPath);
|
||||
// Store the stream URL
|
||||
await AsyncStorage.setItem(currentlyPlaying.url, manifestPath);
|
||||
} else {
|
||||
console.error("Transcoding failed");
|
||||
// Handle failure (e.g., retry or show error message)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error preparing stream:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
prepareStream();
|
||||
|
||||
return () => {
|
||||
// Cleanup: cancel FFmpeg session when component unmounts
|
||||
if (ffmpegSession) {
|
||||
ffmpegSession.cancel();
|
||||
}
|
||||
};
|
||||
}, [currentlyPlaying?.url]);
|
||||
|
||||
// Cleanup function
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const cleanup = async () => {
|
||||
if (streamUrl) {
|
||||
try {
|
||||
// Remove the stream URL from AsyncStorage
|
||||
await AsyncStorage.removeItem(currentlyPlaying?.url || "");
|
||||
// Delete the stream files
|
||||
await FileSystem.deleteAsync(streamUrl.replace("file://", ""), {
|
||||
idempotent: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error cleaning up stream:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
cleanup();
|
||||
};
|
||||
}, [streamUrl, currentlyPlaying?.url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (segments.find((s) => s.includes("tabs"))) {
|
||||
// Tab screen - i.e. home
|
||||
@@ -92,108 +189,28 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
}
|
||||
}, [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(
|
||||
() =>
|
||||
item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(
|
||||
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
|
||||
)
|
||||
: 0,
|
||||
[item]
|
||||
[currentlyPlaying?.item]
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
item: currentlyPlaying?.item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
}),
|
||||
[item]
|
||||
[currentlyPlaying?.item, api]
|
||||
);
|
||||
|
||||
if (!currentlyPlaying || !api) return null;
|
||||
if (!api || !currentlyPlaying) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
@@ -220,10 +237,14 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
videoRef.current?.presentFullscreenPlayer();
|
||||
}}
|
||||
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 && (
|
||||
{streamUrl && (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
allowsExternalPlayback
|
||||
@@ -235,7 +256,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
controls={false}
|
||||
pictureInPicture={true}
|
||||
poster={
|
||||
backdropUrl && item?.Type === "Audio"
|
||||
backdropUrl && currentlyPlaying.item?.Type === "Audio"
|
||||
? backdropUrl
|
||||
: undefined
|
||||
}
|
||||
@@ -243,13 +264,13 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
enable: true,
|
||||
thread: true,
|
||||
}}
|
||||
paused={!playing}
|
||||
paused={!isPlaying}
|
||||
onProgress={(e) => onProgress(e)}
|
||||
subtitleStyle={{
|
||||
fontSize: 16,
|
||||
}}
|
||||
source={{
|
||||
uri: currentlyPlaying.playbackUrl,
|
||||
uri: streamUrl,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
@@ -257,22 +278,18 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
onBuffer={(e) =>
|
||||
e.isBuffering ? console.log("Buffering...") : null
|
||||
}
|
||||
onFullscreenPlayerDidDismiss={() => {
|
||||
setFullScreen(false);
|
||||
}}
|
||||
onFullscreenPlayerDidPresent={() => {
|
||||
setFullScreen(true);
|
||||
}}
|
||||
onFullscreenPlayerDidDismiss={() => {}}
|
||||
onFullscreenPlayerDidPresent={() => {}}
|
||||
onPlaybackStateChanged={(e) => {
|
||||
if (e.isPlaying) {
|
||||
setPlaying(true);
|
||||
setIsPlaying(true);
|
||||
} else if (e.isSeeking) {
|
||||
return;
|
||||
} else {
|
||||
setPlaying(false);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}}
|
||||
progressUpdateInterval={1000}
|
||||
progressUpdateInterval={2000}
|
||||
onError={(e) => {
|
||||
console.log(e);
|
||||
writeToLog(
|
||||
@@ -280,11 +297,11 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
"Video playback error: " + JSON.stringify(e)
|
||||
);
|
||||
Alert.alert("Error", "Cannot play this video file.");
|
||||
setPlaying(false);
|
||||
setCurrentlyPlaying(null);
|
||||
setIsPlaying(false);
|
||||
// setCurrentlyPlaying(null);
|
||||
}}
|
||||
renderLoader={
|
||||
item?.Type !== "Audio" && (
|
||||
currentlyPlaying.item?.Type !== "Audio" && (
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
@@ -296,37 +313,41 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
<View className="shrink text-xs">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (item?.Type === "Audio")
|
||||
router.push(`/albums/${item?.AlbumId}`);
|
||||
else router.push(`/items/${item?.Id}`);
|
||||
if (currentlyPlaying.item?.Type === "Audio")
|
||||
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
||||
else router.push(`/items/${currentlyPlaying.item?.Id}`);
|
||||
}}
|
||||
>
|
||||
<Text>{item?.Name}</Text>
|
||||
<Text>{currentlyPlaying.item?.Name}</Text>
|
||||
</TouchableOpacity>
|
||||
{item?.Type === "Episode" && (
|
||||
{currentlyPlaying.item?.Type === "Episode" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/series/${item.SeriesId}`);
|
||||
router.push(
|
||||
`/(auth)/series/${currentlyPlaying.item.SeriesId}`
|
||||
);
|
||||
}}
|
||||
className="text-xs opacity-50"
|
||||
>
|
||||
<Text>{item.SeriesName}</Text>
|
||||
<Text>{currentlyPlaying.item.SeriesName}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{item?.Type === "Movie" && (
|
||||
{currentlyPlaying.item?.Type === "Movie" && (
|
||||
<View>
|
||||
<Text className="text-xs opacity-50">
|
||||
{item?.ProductionYear}
|
||||
{currentlyPlaying.item?.ProductionYear}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{item?.Type === "Audio" && (
|
||||
{currentlyPlaying.item?.Type === "Audio" && (
|
||||
<TouchableOpacity
|
||||
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>
|
||||
)}
|
||||
</View>
|
||||
@@ -334,12 +355,12 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (playing) setPlaying(false);
|
||||
else setPlaying(true);
|
||||
if (isPlaying) pauseVideo();
|
||||
else playVideo();
|
||||
}}
|
||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||
>
|
||||
{playing ? (
|
||||
{isPlaying ? (
|
||||
<Ionicons name="pause" size={24} color="white" />
|
||||
) : (
|
||||
<Ionicons name="play" size={24} color="white" />
|
||||
@@ -347,7 +368,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setCurrentlyPlaying(null);
|
||||
stopPlayback();
|
||||
}}
|
||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||
>
|
||||
|
||||
@@ -4,37 +4,132 @@ import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewProps,
|
||||
} from "react-native";
|
||||
import { Loader } from "./Loader";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
import { DownloadQuality, useSettings } from "@/utils/atoms/settings";
|
||||
import { useCallback } from "react";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import native from "@/utils/profiles/native";
|
||||
import old from "@/utils/profiles/old";
|
||||
|
||||
type DownloadProps = {
|
||||
interface DownloadProps extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
playbackUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
item,
|
||||
playbackUrl,
|
||||
}) => {
|
||||
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [process] = useAtom(runningProcesses);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
|
||||
const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
|
||||
const [settings] = useSettings();
|
||||
|
||||
const { data: playbackInfo, isLoading } = useQuery({
|
||||
queryKey: ["playbackInfo", item.Id],
|
||||
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
|
||||
});
|
||||
const { startRemuxing } = useRemuxHlsToMp4(item);
|
||||
|
||||
const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({
|
||||
const initiateDownload = useCallback(
|
||||
async (qualitySetting: DownloadQuality) => {
|
||||
if (!api || !user?.Id || !item.Id) {
|
||||
throw new Error(
|
||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||
);
|
||||
}
|
||||
|
||||
let deviceProfile: any = ios;
|
||||
|
||||
if (settings?.deviceProfile === "Native") {
|
||||
deviceProfile = native;
|
||||
} else if (settings?.deviceProfile === "Old") {
|
||||
deviceProfile = old;
|
||||
}
|
||||
|
||||
let maxStreamingBitrate: number | undefined = undefined;
|
||||
|
||||
if (qualitySetting === "high") {
|
||||
maxStreamingBitrate = 8000000;
|
||||
} else if (qualitySetting === "low") {
|
||||
maxStreamingBitrate = 2000000;
|
||||
}
|
||||
|
||||
const response = await api.axiosInstance.post(
|
||||
`${api.basePath}/Items/${item.Id}/PlaybackInfo`,
|
||||
{
|
||||
DeviceProfile: deviceProfile,
|
||||
UserId: user.Id,
|
||||
MaxStreamingBitrate: maxStreamingBitrate,
|
||||
StartTimeTicks: 0,
|
||||
EnableTranscoding: maxStreamingBitrate ? true : undefined,
|
||||
AutoOpenLiveStream: true,
|
||||
MediaSourceId: item.Id,
|
||||
AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let url: string | undefined = undefined;
|
||||
|
||||
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
|
||||
|
||||
if (!mediaSource) {
|
||||
throw new Error("No media source");
|
||||
}
|
||||
|
||||
if (mediaSource.SupportsDirectPlay) {
|
||||
if (item.MediaType === "Video") {
|
||||
console.log("Using direct stream for video!");
|
||||
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true`;
|
||||
} else if (item.MediaType === "Audio") {
|
||||
console.log("Using direct stream for audio!");
|
||||
const searchParams = new URLSearchParams({
|
||||
UserId: user.Id,
|
||||
DeviceId: api.deviceInfo.id,
|
||||
MaxStreamingBitrate: "140000000",
|
||||
Container:
|
||||
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
|
||||
TranscodingContainer: "mp4",
|
||||
TranscodingProtocol: "hls",
|
||||
AudioCodec: "aac",
|
||||
api_key: api.accessToken,
|
||||
StartTimeTicks: "0",
|
||||
EnableRedirection: "true",
|
||||
EnableRemoteMedia: "false",
|
||||
});
|
||||
url = `${api.basePath}/Audio/${
|
||||
item.Id
|
||||
}/universal?${searchParams.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaSource.TranscodingUrl) {
|
||||
console.log("Using transcoded stream!");
|
||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||
} else {
|
||||
throw new Error("No transcoding url");
|
||||
}
|
||||
|
||||
return await startRemuxing(url);
|
||||
},
|
||||
[api, item, startRemuxing, user?.Id]
|
||||
);
|
||||
|
||||
const { data: downloaded, isFetching } = useQuery({
|
||||
queryKey: ["downloaded", item.Id],
|
||||
queryFn: async () => {
|
||||
if (!item.Id) return false;
|
||||
@@ -48,7 +143,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
enabled: !!item.Id,
|
||||
});
|
||||
|
||||
if (isLoading || isLoadingDownloaded) {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Loader />
|
||||
@@ -56,20 +151,13 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
|
||||
return (
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (process && process?.item.Id === item.Id) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
{process.progress === 0 ? (
|
||||
@@ -96,6 +184,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
||||
<Ionicons name="hourglass" size={24} color="white" />
|
||||
@@ -110,6 +199,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||
@@ -123,11 +213,16 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
queueActions.enqueue(queue, setQueue, {
|
||||
id: item.Id!,
|
||||
execute: async () => {
|
||||
await startRemuxing();
|
||||
// await startRemuxing(playbackUrl);
|
||||
if (!settings?.downloadQuality?.value) {
|
||||
throw new Error("No download quality selected");
|
||||
}
|
||||
await initiateDownload(settings?.downloadQuality?.value);
|
||||
},
|
||||
item,
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
||||
|
||||
@@ -8,17 +8,6 @@ type ItemCardProps = {
|
||||
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 }) => {
|
||||
return (
|
||||
<View className="mt-2 flex flex-col h-12">
|
||||
@@ -28,9 +17,7 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||
{item.SeriesName}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||
{`S${seasonNameToIndex(
|
||||
item?.SeasonName,
|
||||
)}:E${item.IndexNumber?.toString()}`}{" "}
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
|
||||
{item.Name}
|
||||
</Text>
|
||||
</>
|
||||
|
||||
@@ -5,26 +5,32 @@ import { useState } from "react";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
text?: string | null;
|
||||
characterLimit?: number;
|
||||
}
|
||||
|
||||
const LIMIT = 140;
|
||||
|
||||
export const OverviewText: React.FC<Props> = ({ text, ...props }) => {
|
||||
const [limit, setLimit] = useState(LIMIT);
|
||||
export const OverviewText: React.FC<Props> = ({
|
||||
text,
|
||||
characterLimit = 140,
|
||||
...props
|
||||
}) => {
|
||||
const [limit, setLimit] = useState(characterLimit);
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
if (text.length > LIMIT)
|
||||
if (text.length > characterLimit)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
setLimit((prev) => (prev === LIMIT ? text.length : LIMIT))
|
||||
setLimit((prev) =>
|
||||
prev === characterLimit ? text.length : characterLimit
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<View {...props} className="">
|
||||
<Text>{tc(text, limit)}</Text>
|
||||
<Text className="text-purple-600 mt-1">
|
||||
{limit === LIMIT ? "Show more" : "Show less"}
|
||||
{limit === characterLimit ? "Show more" : "Show less"}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
onPress: (type?: "cast" | "device") => void;
|
||||
chromecastReady: boolean;
|
||||
item?: BaseItemDto | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
onPress,
|
||||
chromecastReady,
|
||||
...props
|
||||
}) => {
|
||||
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
|
||||
const _onPress = () => {
|
||||
if (!chromecastReady) {
|
||||
onPress("device");
|
||||
const onPress = async () => {
|
||||
if (!url || !item) return;
|
||||
|
||||
if (!client) {
|
||||
setCurrentlyPlayingState({ item, url });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,28 +36,45 @@ export const PlayButton: React.FC<Props> = ({
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
},
|
||||
(selectedIndex: number | undefined) => {
|
||||
async (selectedIndex: number | undefined) => {
|
||||
switch (selectedIndex) {
|
||||
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;
|
||||
case 1:
|
||||
onPress("device");
|
||||
setCurrentlyPlayingState({ item, url });
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
break;
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onPress={_onPress}
|
||||
onPress={onPress}
|
||||
iconRight={
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<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>
|
||||
}
|
||||
{...props}
|
||||
|
||||
@@ -22,14 +22,14 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
const subtitleStreams = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.[0].MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
(x) => x.Type === "Subtitle"
|
||||
) ?? [],
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
() => subtitleStreams.find((x) => x.Index === selected),
|
||||
[subtitleStreams, selected],
|
||||
[subtitleStreams, selected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,7 +50,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="">
|
||||
{selectedSubtitleSteam
|
||||
? tc(selectedSubtitleSteam?.DisplayTitle, 13)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FlashList, FlashListProps } from "@shopify/flash-list";
|
||||
import React, { useEffect } from "react";
|
||||
import { ScrollView, ScrollViewProps, View, ViewStyle } from "react-native";
|
||||
import { View, ViewStyle } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -8,7 +9,13 @@ import Animated, {
|
||||
import { Loader } from "../Loader";
|
||||
import { Text } from "./Text";
|
||||
|
||||
interface HorizontalScrollProps<T> extends ScrollViewProps {
|
||||
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||
|
||||
interface HorizontalScrollProps<T>
|
||||
extends PartialExcept<
|
||||
Omit<FlashListProps<T>, "renderItem">,
|
||||
"estimatedItemSize"
|
||||
> {
|
||||
data?: T[] | null;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
containerStyle?: ViewStyle;
|
||||
@@ -58,31 +65,31 @@ export function HorizontalScroll<T>({
|
||||
);
|
||||
}
|
||||
|
||||
const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
|
||||
<View className="mr-2">
|
||||
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
style={containerStyle}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
{...props}
|
||||
>
|
||||
<Animated.View
|
||||
className={`
|
||||
flex flex-row px-4
|
||||
`}
|
||||
style={[animatedStyle1]}
|
||||
>
|
||||
{data.map((item, index) => (
|
||||
<View className="mr-2" key={index}>
|
||||
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||
</View>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<Animated.View style={[containerStyle, animatedStyle1]}>
|
||||
<FlashList
|
||||
data={data}
|
||||
renderItem={renderFlashListItem}
|
||||
horizontal
|
||||
estimatedItemSize={100}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
...contentContainerStyle,
|
||||
}}
|
||||
ListEmptyComponent={() => (
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<Text className="text-center text-gray-500">No data available</Text>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
{...props}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
NativeScrollEvent,
|
||||
ScrollView,
|
||||
ScrollViewProps,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native";
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { FlashList, FlashListProps } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { View, ViewStyle } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -13,16 +15,9 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Loader } from "../Loader";
|
||||
import { Text } from "./Text";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
interface HorizontalScrollProps extends ScrollViewProps {
|
||||
interface HorizontalScrollProps
|
||||
extends Omit<FlashListProps<BaseItemDto>, "renderItem" | "data" | "style"> {
|
||||
queryFn: ({
|
||||
pageParam,
|
||||
}: {
|
||||
@@ -38,18 +33,6 @@ interface HorizontalScrollProps extends ScrollViewProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const isCloseToBottom = ({
|
||||
layoutMeasurement,
|
||||
contentOffset,
|
||||
contentSize,
|
||||
}: NativeScrollEvent) => {
|
||||
const paddingToBottom = 50;
|
||||
return (
|
||||
layoutMeasurement.height + contentOffset.y >=
|
||||
contentSize.height - paddingToBottom
|
||||
);
|
||||
};
|
||||
|
||||
export function InfiniteHorizontalScroll({
|
||||
queryFn,
|
||||
queryKey,
|
||||
@@ -64,7 +47,6 @@ export function InfiniteHorizontalScroll({
|
||||
}: HorizontalScrollProps): React.ReactElement {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
|
||||
const animatedOpacity = useSharedValue(0);
|
||||
const animatedStyle1 = useAnimatedStyle(() => {
|
||||
@@ -73,7 +55,7 @@ export function InfiniteHorizontalScroll({
|
||||
};
|
||||
});
|
||||
|
||||
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
@@ -100,6 +82,13 @@ export function InfiniteHorizontalScroll({
|
||||
enabled: !!api && !!user?.Id,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
return (
|
||||
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
||||
[]
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
animatedOpacity.value = 1;
|
||||
@@ -124,41 +113,34 @@ export function InfiniteHorizontalScroll({
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
onScroll={({ nativeEvent }) => {
|
||||
if (isCloseToBottom(nativeEvent)) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
scrollEventThrottle={400}
|
||||
style={containerStyle}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
{...props}
|
||||
>
|
||||
<Animated.View
|
||||
className={`
|
||||
flex flex-row px-4
|
||||
`}
|
||||
style={[animatedStyle1]}
|
||||
>
|
||||
{data?.pages
|
||||
.flatMap((page) => page?.Items)
|
||||
.map(
|
||||
(item, index) =>
|
||||
item && (
|
||||
<View className="mr-2" key={index}>
|
||||
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
{data?.pages.flatMap((page) => page?.Items).length === 0 && (
|
||||
<Animated.View style={[containerStyle, animatedStyle1]}>
|
||||
<FlashList
|
||||
data={flatData}
|
||||
renderItem={({ item, index }) => (
|
||||
<View className="mr-2">
|
||||
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||
</View>
|
||||
)}
|
||||
estimatedItemSize={height}
|
||||
horizontal
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
...contentContainerStyle,
|
||||
}}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<Text className="text-center text-gray-500">No data available</Text>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,8 +45,11 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
router.push(`/artists/${item.Id}/page`);
|
||||
return;
|
||||
}
|
||||
if (item.Type === "Person") {
|
||||
router.push(`/actors/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Movies and all other cases
|
||||
if (item.Type === "BoxSet") {
|
||||
router.push(`/collections/${item.Id}`);
|
||||
return;
|
||||
|
||||
29
components/common/VerticalSkeleton.tsx
Normal file
29
components/common/VerticalSkeleton.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
style={{
|
||||
width: "32%",
|
||||
}}
|
||||
className="flex flex-col"
|
||||
{...props}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
className="w-full bg-neutral-800 mb-2 rounded-lg"
|
||||
></View>
|
||||
<View className="h-2 bg-neutral-800 rounded-full mb-1"></View>
|
||||
<View className="h-2 bg-neutral-800 rounded-full mb-1"></View>
|
||||
<View className="h-2 bg-neutral-800 rounded-full mb-2 w-1/2"></View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -8,12 +8,8 @@ import { useAtom } from "jotai";
|
||||
|
||||
import { Text } from "../common/Text";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
fullScreenAtom,
|
||||
playingAtom,
|
||||
} from "../CurrentlyPlayingBar";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
|
||||
interface EpisodeCardProps {
|
||||
item: BaseItemDto;
|
||||
@@ -26,23 +22,15 @@ interface EpisodeCardProps {
|
||||
*/
|
||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
|
||||
const [, setPlaying] = useAtom(playingAtom);
|
||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
||||
const [settings] = useSettings();
|
||||
|
||||
/**
|
||||
* Handles opening the file for playback.
|
||||
*/
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
|
||||
const handleOpenFile = useCallback(async () => {
|
||||
setCurrentlyPlaying({
|
||||
setCurrentlyPlayingState({
|
||||
item,
|
||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
setPlaying(true);
|
||||
if (settings?.openFullScreenVideoPlayerByDefault === true)
|
||||
setFullscreen(true);
|
||||
}, [item, setCurrentlyPlaying, settings]);
|
||||
}, [item, setCurrentlyPlayingState]);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
|
||||
@@ -9,12 +9,9 @@ import { useAtom } from "jotai";
|
||||
import { Text } from "../common/Text";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
fullScreenAtom,
|
||||
playingAtom,
|
||||
} from "../CurrentlyPlayingBar";
|
||||
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
|
||||
interface MovieCardProps {
|
||||
item: BaseItemDto;
|
||||
@@ -27,25 +24,16 @@ interface MovieCardProps {
|
||||
*/
|
||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
|
||||
const [, setPlaying] = useAtom(playingAtom);
|
||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
||||
const [settings] = useSettings();
|
||||
|
||||
/**
|
||||
* Handles opening the file for playback.
|
||||
*/
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
console.log("Open movie file", item.Name);
|
||||
setCurrentlyPlaying({
|
||||
setCurrentlyPlayingState({
|
||||
item,
|
||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
setPlaying(true);
|
||||
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
||||
setFullscreen(true);
|
||||
}
|
||||
}, [item, setCurrentlyPlaying, setPlaying, settings]);
|
||||
}, [item, setCurrentlyPlayingState]);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
@@ -81,7 +69,12 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Content
|
||||
loop={false}
|
||||
alignOffset={0}
|
||||
avoidCollisions={false}
|
||||
collisionPadding={0}
|
||||
>
|
||||
{contextMenuOptions.map((option) => (
|
||||
<ContextMenu.Item
|
||||
key={option.label}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { FilterSheet } from "./FilterSheet";
|
||||
|
||||
@@ -36,16 +34,19 @@ export const FilterButton = <T,>({
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: filters } = useQuery<T[]>({
|
||||
queryKey: [queryKey, collectionId],
|
||||
queryKey: ["filters", title, queryKey, collectionId],
|
||||
queryFn,
|
||||
staleTime: 0,
|
||||
enabled: !!collectionId && !!queryFn && !!queryKey,
|
||||
});
|
||||
|
||||
if (filters?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity onPress={() => setOpen(true)}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
filters?.length && setOpen(true);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={`
|
||||
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
||||
@@ -54,12 +55,13 @@ export const FilterButton = <T,>({
|
||||
? "bg-purple-600 border border-purple-700"
|
||||
: "bg-neutral-900 border border-neutral-900"
|
||||
}
|
||||
${filters?.length === 0 && "opacity-50"}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
<Text
|
||||
className={`
|
||||
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
|
||||
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
|
||||
text-xs font-semibold`}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -34,6 +34,34 @@ interface Props<T> extends ViewProps {
|
||||
|
||||
const LIMIT = 100;
|
||||
|
||||
/**
|
||||
* FilterSheet Component
|
||||
*
|
||||
* This component creates a bottom sheet modal for filtering and selecting items from a list.
|
||||
*
|
||||
* @template T - The type of items in the list
|
||||
*
|
||||
* @param {Object} props - The component props
|
||||
* @param {boolean} props.open - Whether the bottom sheet is open
|
||||
* @param {function} props.setOpen - Function to set the open state
|
||||
* @param {T[] | null} [props.data] - The full list of items to filter from
|
||||
* @param {T[]} props.values - The currently selected items
|
||||
* @param {function} props.set - Function to update the selected items
|
||||
* @param {string} props.title - The title of the bottom sheet
|
||||
* @param {function} props.searchFilter - Function to filter items based on search query
|
||||
* @param {function} props.renderItemLabel - Function to render the label for each item
|
||||
* @param {boolean} [props.showSearch=true] - Whether to show the search input
|
||||
*
|
||||
* @returns {React.ReactElement} The FilterSheet component
|
||||
*
|
||||
* Features:
|
||||
* - Displays a list of items in a bottom sheet
|
||||
* - Allows searching and filtering of items
|
||||
* - Supports single selection of items
|
||||
* - Loads items in batches for performance optimization
|
||||
* - Customizable item rendering
|
||||
*/
|
||||
|
||||
export const FilterSheet = <T,>({
|
||||
values,
|
||||
data: _data,
|
||||
@@ -65,6 +93,8 @@ export const FilterSheet = <T,>({
|
||||
return results.slice(0, 100);
|
||||
}, [search, _data, searchFilter]);
|
||||
|
||||
// Loads data in batches of LIMIT size, starting from offset,
|
||||
// to implement efficient "load more" functionality
|
||||
useEffect(() => {
|
||||
if (!_data || _data.length === 0) return;
|
||||
const tmp = new Set(data);
|
||||
@@ -143,19 +173,16 @@ export const FilterSheet = <T,>({
|
||||
className="mb-4 flex flex-col rounded-xl overflow-hidden"
|
||||
>
|
||||
{renderData?.map((item, index) => (
|
||||
<>
|
||||
<View key={index}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
set(
|
||||
values.includes(item)
|
||||
? values.filter((i) => i !== item)
|
||||
: [item]
|
||||
);
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, 250);
|
||||
if (!values.includes(item)) {
|
||||
set([item]);
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, 250);
|
||||
}
|
||||
}}
|
||||
key={index}
|
||||
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
|
||||
>
|
||||
<Text>{renderItemLabel(item)}</Text>
|
||||
@@ -171,7 +198,7 @@ export const FilterSheet = <T,>({
|
||||
}}
|
||||
className="h-1 divide-neutral-700 "
|
||||
></View>
|
||||
</>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{data.length < (_data?.length || 0) && (
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
||||
setSelectedTags([]);
|
||||
setSelectedYears([]);
|
||||
}}
|
||||
className="bg-purple-600 rounded-full w-8 h-8 flex items-center justify-center"
|
||||
className="bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1"
|
||||
{...props}
|
||||
>
|
||||
<Ionicons name="close" size={20} color="white" />
|
||||
|
||||
@@ -31,6 +31,25 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: sf_carousel, isFetching: l1 } = useQuery({
|
||||
queryKey: ["sf_carousel", user?.Id, settings?.mediaListCollectionIds],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
tags: ["sf_carousel"],
|
||||
recursive: true,
|
||||
fields: ["Tags"],
|
||||
includeItemTypes: ["BoxSet"],
|
||||
});
|
||||
|
||||
return response.data.Items?.[0].Id || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const onPressPagination = (index: number) => {
|
||||
ref.current?.scrollTo({
|
||||
/**
|
||||
@@ -42,40 +61,20 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const { data: mediaListCollection, isLoading: l1 } = useQuery<string | null>({
|
||||
queryKey: ["mediaListCollection", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
tags: ["medialist", "promoted"],
|
||||
recursive: true,
|
||||
fields: ["Tags"],
|
||||
includeItemTypes: ["BoxSet"],
|
||||
});
|
||||
|
||||
const id = response.data.Items?.find((c) => c.Name === "sf_carousel")?.Id;
|
||||
return id || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: popularItems, isLoading: l2 } = useQuery<BaseItemDto[]>({
|
||||
const { data: popularItems, isFetching: l2 } = useQuery<BaseItemDto[]>({
|
||||
queryKey: ["popular", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !mediaListCollection) return [];
|
||||
if (!api || !user?.Id || !sf_carousel) return [];
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
parentId: mediaListCollection,
|
||||
parentId: sf_carousel,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!mediaListCollection,
|
||||
enabled: !!api && !!user?.Id && !!sf_carousel,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
@@ -94,7 +93,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||
<View className="flex flex-col items-center" {...props}>
|
||||
<Carousel
|
||||
autoPlay={true}
|
||||
autoPlayInterval={2000}
|
||||
autoPlayInterval={3000}
|
||||
loop={true}
|
||||
ref={ref}
|
||||
width={width}
|
||||
@@ -124,14 +123,16 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const screenWidth = Dimensions.get("screen").width;
|
||||
|
||||
const uri = useMemo(() => {
|
||||
if (!api) return null;
|
||||
|
||||
return getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
quality: 70,
|
||||
width: Math.floor(screenWidth * 0.8 * 2),
|
||||
});
|
||||
}, [api, item]);
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
key={index}
|
||||
item={item}
|
||||
className={`flex flex-col
|
||||
${orientation === "vertical" ? "w-32" : "w-48"}
|
||||
${orientation === "vertical" ? "w-28" : "w-44"}
|
||||
`}
|
||||
>
|
||||
<View>
|
||||
|
||||
@@ -4,15 +4,14 @@ import {
|
||||
BaseItemDtoQueryResult,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { ScrollingCollectionList } from "../home/ScrollingCollectionList";
|
||||
import { Text } from "../common/Text";
|
||||
import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import MoviePoster from "../posters/MoviePoster";
|
||||
import { useCallback } from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import MoviePoster from "../posters/MoviePoster";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
collection: BaseItemDto;
|
||||
@@ -34,7 +33,7 @@ export const MediaListSection: React.FC<Props> = ({ collection, ...props }) => {
|
||||
userId: user.Id,
|
||||
parentId: collection.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
limit: 8,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
@@ -56,11 +55,12 @@ export const MediaListSection: React.FC<Props> = ({ collection, ...props }) => {
|
||||
key={index}
|
||||
item={item}
|
||||
className={`flex flex-col
|
||||
${"w-32"}
|
||||
${"w-28"}
|
||||
`}
|
||||
>
|
||||
<View>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
|
||||
@@ -10,12 +10,8 @@ interface Props extends ViewProps {
|
||||
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<View className="flex flex-row items-center self-center px-4">
|
||||
<Text className="text-center font-bold text-2xl mr-2">
|
||||
{item?.Name}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
<View className="flex flex-row items-center self-center px-4" {...props}>
|
||||
<Text className="text-center font-bold text-2xl mr-2">{item?.Name}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import ArtistPoster from "../ArtistPoster";
|
||||
import { runtimeTicksToMinutes, runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { useRouter } from "expo-router";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { SongsListItem } from "./SongsListItem";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
import {
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewProps,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import index from "@/app/(auth)/(tabs)/home";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { router } from "expo-router";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom } from "jotai";
|
||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { currentlyPlayingItemAtom, playingAtom } from "../CurrentlyPlayingBar";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
collectionId: string;
|
||||
@@ -42,12 +35,12 @@ export const SongsListItem: React.FC<Props> = ({
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const castDevice = useCastDevice();
|
||||
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
const [, setPlaying] = useAtom(playingAtom);
|
||||
|
||||
const client = useRemoteMediaClient();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
|
||||
const openSelect = () => {
|
||||
if (!castDevice?.deviceId) {
|
||||
play("device");
|
||||
@@ -73,7 +66,7 @@ export const SongsListItem: React.FC<Props> = ({
|
||||
case cancelButtonIndex:
|
||||
break;
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -118,11 +111,10 @@ export const SongsListItem: React.FC<Props> = ({
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCp({
|
||||
setCurrentlyPlayingState({
|
||||
item,
|
||||
playbackUrl: url,
|
||||
url,
|
||||
});
|
||||
setPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ const AlbumCover: React.FC<ArtistPosterProps> = ({ item, id }) => {
|
||||
|
||||
if (!item && id)
|
||||
return (
|
||||
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
key={id}
|
||||
id={id}
|
||||
|
||||
@@ -29,7 +29,7 @@ const ArtistPoster: React.FC<ArtistPosterProps> = ({
|
||||
if (!url)
|
||||
return (
|
||||
<View
|
||||
className="rounded-md overflow-hidden border border-neutral-900"
|
||||
className="rounded-lg overflow-hidden border border-neutral-900"
|
||||
style={{
|
||||
aspectRatio: "1/1",
|
||||
}}
|
||||
|
||||
@@ -23,6 +23,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
width: 300,
|
||||
}),
|
||||
[item]
|
||||
);
|
||||
@@ -37,7 +38,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
}, [item]);
|
||||
|
||||
return (
|
||||
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
placeholder={{
|
||||
blurhash,
|
||||
|
||||
@@ -28,7 +28,7 @@ const ParentPoster: React.FC<PosterProps> = ({ id }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="rounded-md overflow-hidden border border-neutral-900">
|
||||
<View className="rounded-lg overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
key={id}
|
||||
id={id}
|
||||
|
||||
@@ -24,7 +24,7 @@ const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="rounded-md overflow-hidden border border-neutral-900">
|
||||
<View className="rounded-lg overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
placeholder={
|
||||
blurhash
|
||||
|
||||
@@ -30,7 +30,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
||||
}, [item]);
|
||||
|
||||
return (
|
||||
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
placeholder={{
|
||||
blurhash,
|
||||
|
||||
@@ -10,11 +10,16 @@ import Poster from "../posters/Poster";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { router } from "expo-router";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const [settings] = useSettings();
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
||||
@@ -23,7 +28,7 @@ export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
// TODO: Navigate to person
|
||||
router.push(`/actors/${item.Id}`);
|
||||
}}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-32"
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
import Poster from "../posters/Poster";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { router } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { nextUp } from "@/utils/jellyfin/tvshows/nextUp";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -26,6 +24,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
userId: user?.Id,
|
||||
seriesId,
|
||||
fields: ["MediaSourceCount"],
|
||||
limit: 10,
|
||||
})
|
||||
).data.Items;
|
||||
},
|
||||
@@ -52,7 +51,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
router.push(`/(auth)/items/${item.Id}`);
|
||||
}}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-32"
|
||||
className="flex flex-col w-44"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { DownloadItem } from "../DownloadItem";
|
||||
import { Loader } from "../Loader";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
};
|
||||
|
||||
export const seasonIndexAtom = atom<number>(1);
|
||||
type SeasonIndexState = {
|
||||
[seriesId: string]: number;
|
||||
};
|
||||
|
||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||
|
||||
export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [seasonIndex, setSeasonIndex] = useAtom(seasonIndexAtom);
|
||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||
|
||||
const seasonIndex = seasonIndexState[item.Id ?? ""];
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -40,7 +47,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.Items;
|
||||
@@ -48,13 +55,24 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
enabled: !!api && !!user?.Id && !!item.Id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
||||
const firstSeason = seasons[0];
|
||||
if (firstSeason.IndexNumber !== undefined) {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.Id ?? ""]: firstSeason.IndexNumber,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [seasons, seasonIndex, setSeasonIndexState, item.Id]);
|
||||
const selectedSeasonId: string | null = useMemo(
|
||||
() =>
|
||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||
[seasons, seasonIndex],
|
||||
[seasons, seasonIndex]
|
||||
);
|
||||
|
||||
const { data: episodes } = useQuery({
|
||||
const { data: episodes, isFetching } = useQuery({
|
||||
queryKey: ["episodes", item.Id, selectedSeasonId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item.Id) return [];
|
||||
@@ -70,7 +88,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.Items as BaseItemDto[];
|
||||
@@ -78,8 +96,20 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||
});
|
||||
|
||||
// Used for height calculation
|
||||
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
|
||||
useEffect(() => {
|
||||
if (episodes && episodes.length > 0) {
|
||||
setNrOfEpisodes(episodes.length);
|
||||
}
|
||||
}, [episodes]);
|
||||
|
||||
return (
|
||||
<View className="mb-2">
|
||||
<View
|
||||
style={{
|
||||
minHeight: 144 * nrOfEpisodes,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-row px-4">
|
||||
@@ -102,7 +132,10 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
<DropdownMenu.Item
|
||||
key={season.Name}
|
||||
onSelect={() => {
|
||||
setSeasonIndex(season.IndexNumber);
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.Id ?? ""]: season.IndexNumber,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
|
||||
@@ -110,7 +143,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{episodes && (
|
||||
{/* Old View. Might have a setting later to manually select view. */}
|
||||
{/* {episodes && (
|
||||
<View className="mt-4">
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={episodes}
|
||||
@@ -128,7 +162,56 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
)} */}
|
||||
<View className="px-4 flex flex-col my-4">
|
||||
{isFetching ? (
|
||||
<View
|
||||
style={{
|
||||
minHeight: 144 * nrOfEpisodes,
|
||||
}}
|
||||
className="flex flex-col items-center justify-center"
|
||||
>
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
episodes?.map((e: BaseItemDto) => (
|
||||
<TouchableOpacity
|
||||
key={e.Id}
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/items/${e.Id}`);
|
||||
}}
|
||||
className="flex flex-col mb-4"
|
||||
>
|
||||
<View className="flex flex-row items-center mb-2">
|
||||
<View className="w-32 aspect-video overflow-hidden mr-2">
|
||||
<ContinueWatchingPoster item={e} width={128} />
|
||||
</View>
|
||||
<View className="shrink">
|
||||
<Text numberOfLines={2} className="">
|
||||
{e.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
||||
{`S${e.ParentIndexNumber?.toString()}:E${e.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-500">
|
||||
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="self-start ml-auto">
|
||||
<DownloadItem item={e} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
numberOfLines={3}
|
||||
className="text-xs text-neutral-500 shrink"
|
||||
>
|
||||
{e.Overview}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { DownloadOptions, useSettings } from "@/utils/atoms/settings";
|
||||
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 { Linking, Switch, TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { Loader } from "../Loader";
|
||||
import { Input } from "../common/Input";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../Button";
|
||||
|
||||
export const SettingToggles: React.FC = () => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
@@ -14,26 +17,27 @@ export const SettingToggles: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [marlinUrl, setMarlinUrl] = useState<string>("");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: mediaListCollections,
|
||||
isLoading: isLoadingMediaListCollections,
|
||||
} = useQuery({
|
||||
queryKey: ["mediaListCollections", user?.Id],
|
||||
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
tags: ["medialist", "promoted"],
|
||||
tags: ["sf_promoted"],
|
||||
recursive: true,
|
||||
fields: ["Tags"],
|
||||
includeItemTypes: ["BoxSet"],
|
||||
});
|
||||
|
||||
const ids =
|
||||
response.data.Items?.filter((c) => c.Name !== "sf_carousel") ?? [];
|
||||
|
||||
return ids;
|
||||
return response.data.Items ?? [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||
staleTime: 0,
|
||||
@@ -54,6 +58,46 @@ export const SettingToggles: React.FC = () => {
|
||||
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Download quality</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose the search engine you want to use.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>{settings?.downloadQuality?.label}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Quality</DropdownMenu.Label>
|
||||
{DownloadOptions.map((option) => (
|
||||
<DropdownMenu.Item
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
updateSettings({ downloadQuality: option });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold">Start videos in fullscreen</Text>
|
||||
@@ -69,6 +113,23 @@ export const SettingToggles: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Use external player (VLC)</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
Open all videos in VLC instead of the default player. This requries
|
||||
VLC to be installed on the phone.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.openInVLC}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col">
|
||||
@@ -152,6 +213,7 @@ export const SettingToggles: React.FC = () => {
|
||||
onValueChange={(value) => updateSettings({ forceDirectPlay: value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
@@ -208,6 +270,89 @@ export const SettingToggles: React.FC = () => {
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
<View className="flex flex-col">
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.6.1",
|
||||
"channel": "0.8.2",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.6.1",
|
||||
"channel": "0.8.2",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -26,11 +26,12 @@ export const useFiles = () => {
|
||||
fileNames.map((item) =>
|
||||
FileSystem.deleteAsync(`${directoryUri}/${item}`, {
|
||||
idempotent: true,
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
await AsyncStorage.removeItem("downloaded_files");
|
||||
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["downloaded"] });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete all files:", error);
|
||||
}
|
||||
@@ -49,7 +50,7 @@ export const useFiles = () => {
|
||||
try {
|
||||
await FileSystem.deleteAsync(
|
||||
`${FileSystem.documentDirectory}/${id}.mp4`,
|
||||
{ idempotent: true },
|
||||
{ idempotent: true }
|
||||
);
|
||||
|
||||
const currentFiles = await getDownloadedFiles();
|
||||
@@ -57,7 +58,7 @@ export const useFiles = () => {
|
||||
|
||||
await AsyncStorage.setItem(
|
||||
"downloaded_files",
|
||||
JSON.stringify(updatedFiles),
|
||||
JSON.stringify(updatedFiles)
|
||||
);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
||||
|
||||
@@ -6,6 +6,8 @@ import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
/**
|
||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||
@@ -14,8 +16,9 @@ import { writeToLog } from "@/utils/log";
|
||||
* @param item - The BaseItemDto object representing the media item
|
||||
* @returns An object with remuxing-related functions
|
||||
*/
|
||||
export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
||||
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
const [_, setProgress] = useAtom(runningProcesses);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (!item.Id || !item.Name) {
|
||||
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
||||
@@ -23,87 +26,104 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
||||
}
|
||||
|
||||
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
||||
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||
|
||||
const startRemuxing = useCallback(async () => {
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`,
|
||||
);
|
||||
const startRemuxing = useCallback(
|
||||
async (url: string) => {
|
||||
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||
|
||||
try {
|
||||
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
|
||||
// let command: string | null = null;
|
||||
|
||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||
const videoLength =
|
||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
||||
const totalFrames = videoLength * fps;
|
||||
const processedFrames = statistics.getVideoFrameNumber();
|
||||
const speed = statistics.getSpeed();
|
||||
// if (Platform.OS === "android") {
|
||||
// command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c:v h264_mediacodec -c:a copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||
// } else if (Platform.OS === "ios") {
|
||||
// command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c:v h264_videotoolbox -c:a copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||
// } else {
|
||||
// throw new Error("Unsupported platform");
|
||||
// }
|
||||
|
||||
const percentage =
|
||||
totalFrames > 0
|
||||
? Math.floor((processedFrames / totalFrames) * 100)
|
||||
: 0;
|
||||
|
||||
setProgress((prev) =>
|
||||
prev?.item.Id === item.Id!
|
||||
? { ...prev, progress: percentage, speed }
|
||||
: prev,
|
||||
);
|
||||
});
|
||||
|
||||
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
FFmpegKit.executeAsync(command, async (session) => {
|
||||
try {
|
||||
const returnCode = await session.getReturnCode();
|
||||
|
||||
if (returnCode.isValueSuccess()) {
|
||||
await updateDownloadedFiles(item);
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
|
||||
);
|
||||
resolve();
|
||||
} else if (returnCode.isValueError()) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
||||
);
|
||||
reject(new Error("Remuxing failed")); // Reject the promise on error
|
||||
} else if (returnCode.isValueCancel()) {
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
|
||||
);
|
||||
resolve();
|
||||
}
|
||||
|
||||
setProgress(null);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to remux:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
|
||||
);
|
||||
setProgress(null);
|
||||
throw error; // Re-throw the error to propagate it to the caller
|
||||
}
|
||||
}, [output, item, command, setProgress]);
|
||||
|
||||
try {
|
||||
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
|
||||
|
||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||
const videoLength =
|
||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
||||
const totalFrames = videoLength * fps;
|
||||
const processedFrames = statistics.getVideoFrameNumber();
|
||||
const speed = statistics.getSpeed();
|
||||
|
||||
const percentage =
|
||||
totalFrames > 0
|
||||
? Math.floor((processedFrames / totalFrames) * 100)
|
||||
: 0;
|
||||
|
||||
setProgress((prev) =>
|
||||
prev?.item.Id === item.Id!
|
||||
? { ...prev, progress: percentage, speed }
|
||||
: prev
|
||||
);
|
||||
});
|
||||
|
||||
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
FFmpegKit.executeAsync(command, async (session) => {
|
||||
try {
|
||||
const returnCode = await session.getReturnCode();
|
||||
|
||||
if (returnCode.isValueSuccess()) {
|
||||
await updateDownloadedFiles(item);
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
|
||||
);
|
||||
resolve();
|
||||
} else if (returnCode.isValueError()) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||
);
|
||||
reject(new Error("Remuxing failed")); // Reject the promise on error
|
||||
} else if (returnCode.isValueCancel()) {
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`
|
||||
);
|
||||
resolve();
|
||||
}
|
||||
|
||||
setProgress(null);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["downloaded"] });
|
||||
} catch (error) {
|
||||
console.error("Failed to remux:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||
);
|
||||
setProgress(null);
|
||||
throw error; // Re-throw the error to propagate it to the caller
|
||||
}
|
||||
},
|
||||
[output, item, setProgress]
|
||||
);
|
||||
|
||||
const cancelRemuxing = useCallback(() => {
|
||||
FFmpegKit.cancel();
|
||||
setProgress(null);
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`,
|
||||
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`
|
||||
);
|
||||
}, [item.Name, setProgress]);
|
||||
|
||||
@@ -118,7 +138,7 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
||||
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
||||
try {
|
||||
const currentFiles: BaseItemDto[] = JSON.parse(
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||
);
|
||||
const updatedFiles = [
|
||||
...currentFiles.filter((i) => i.Id !== item.Id),
|
||||
@@ -126,13 +146,13 @@ async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
||||
];
|
||||
await AsyncStorage.setItem(
|
||||
"downloaded_files",
|
||||
JSON.stringify(updatedFiles),
|
||||
JSON.stringify(updatedFiles)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error updating downloaded files:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`Failed to update downloaded files for item: ${item.Name}`,
|
||||
`Failed to update downloaded files for item: ${item.Name}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"expo-device": "~6.0.2",
|
||||
"expo-font": "~12.0.9",
|
||||
"expo-haptics": "~13.0.1",
|
||||
"expo-image": "~1.12.13",
|
||||
"expo-image": "~1.12.14",
|
||||
"expo-keep-awake": "~13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-navigation-bar": "~3.0.7",
|
||||
@@ -85,7 +85,7 @@
|
||||
"@types/react": "~18.2.45",
|
||||
"@types/react-test-renderer": "^18.0.7",
|
||||
"jest": "^29.2.1",
|
||||
"jest-expo": "~51.0.3",
|
||||
"jest-expo": "~51.0.4",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"typescript": "~5.3.3"
|
||||
},
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
playingAtom,
|
||||
showCurrentlyPlayingBarAtom,
|
||||
} from "@/utils/atoms/playState";
|
||||
import { Api, Jellyfin } from "@jellyfin/sdk";
|
||||
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isLoaded } from "expo-font";
|
||||
import axios from "axios";
|
||||
import { router, useSegments } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import React, {
|
||||
@@ -21,6 +26,7 @@ interface Server {
|
||||
|
||||
export const apiAtom = atom<Api | null>(null);
|
||||
export const userAtom = atom<UserDto | null>(null);
|
||||
export const wsAtom = atom<WebSocket | null>(null);
|
||||
|
||||
interface JellyfinContextValue {
|
||||
discoverServers: (url: string) => Promise<Server[]>;
|
||||
@@ -31,7 +37,7 @@ interface JellyfinContextValue {
|
||||
}
|
||||
|
||||
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const getOrSetDeviceId = async () => {
|
||||
@@ -49,6 +55,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
|
||||
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
|
||||
const [isConnected, setIsConnected] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -56,10 +64,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.6.1" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.8.2" },
|
||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||
}),
|
||||
})
|
||||
);
|
||||
setDeviceId(id);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
@@ -67,8 +76,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const [user, setUser] = useAtom(userAtom);
|
||||
|
||||
const discoverServers = async (url: string): Promise<Server[]> => {
|
||||
const servers =
|
||||
await jellyfin?.discovery.getRecommendedServerCandidates(url);
|
||||
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
|
||||
url
|
||||
);
|
||||
return servers?.map((server) => ({ address: server.address })) || [];
|
||||
};
|
||||
|
||||
@@ -106,15 +116,40 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}) => {
|
||||
if (!api || !jellyfin) throw new Error("API not initialized");
|
||||
|
||||
const auth = await api.authenticateUserByName(username, password);
|
||||
try {
|
||||
const auth = await api.authenticateUserByName(username, password);
|
||||
|
||||
if (auth.data.AccessToken && auth.data.User) {
|
||||
setUser(auth.data.User);
|
||||
await AsyncStorage.setItem("user", JSON.stringify(auth.data.User));
|
||||
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
||||
await AsyncStorage.setItem("token", auth.data?.AccessToken);
|
||||
} else {
|
||||
throw new Error("Invalid username or password");
|
||||
if (auth.data.AccessToken && auth.data.User) {
|
||||
setUser(auth.data.User);
|
||||
await AsyncStorage.setItem("user", JSON.stringify(auth.data.User));
|
||||
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
||||
await AsyncStorage.setItem("token", auth.data?.AccessToken);
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.log("Axios error", error.response?.status);
|
||||
switch (error.response?.status) {
|
||||
case 401:
|
||||
throw new Error("Invalid username or password");
|
||||
case 403:
|
||||
throw new Error("User does not have permission to log in");
|
||||
case 408:
|
||||
throw new Error(
|
||||
"Server is taking too long to respond, try again later"
|
||||
);
|
||||
case 429:
|
||||
throw new Error(
|
||||
"Server received too many requests, try again later"
|
||||
);
|
||||
case 500:
|
||||
throw new Error("There is a server error");
|
||||
default:
|
||||
throw new Error(
|
||||
"An unexpected error occurred. Did you enter the server URL correctly?"
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -144,7 +179,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const token = await AsyncStorage.getItem("token");
|
||||
const serverUrl = await AsyncStorage.getItem("serverUrl");
|
||||
const user = JSON.parse(
|
||||
(await AsyncStorage.getItem("user")) as string,
|
||||
(await AsyncStorage.getItem("user")) as string
|
||||
) as UserDto;
|
||||
|
||||
if (serverUrl && token && user.Id && jellyfin) {
|
||||
|
||||
281
providers/PlaybackProvider.tsx
Normal file
281
providers/PlaybackProvider.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
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";
|
||||
import * as Linking from "expo-linking";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
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 = useCallback(
|
||||
(state: CurrentlyPlayingState | null) => {
|
||||
const vlcLink = "vlc://" + state?.url;
|
||||
console.log(vlcLink, settings?.openInVLC, Platform.OS === "ios");
|
||||
if (vlcLink && settings?.openInVLC) {
|
||||
Linking.openURL("vlc://" + state?.url || "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state) {
|
||||
setCurrentlyPlaying(state);
|
||||
setIsPlaying(true);
|
||||
|
||||
if (settings?.openFullScreenVideoPlayerByDefault)
|
||||
presentFullscreenPlayer();
|
||||
} else {
|
||||
setCurrentlyPlaying(null);
|
||||
setIsFullscreen(false);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
},
|
||||
[settings]
|
||||
);
|
||||
|
||||
// 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?.accessToken) 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, user]);
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -13,7 +13,9 @@ export const sortOptions: {
|
||||
{ key: "SortName", value: "Name" },
|
||||
{ key: "CommunityRating", value: "Community Rating" },
|
||||
{ key: "CriticRating", value: "Critics Rating" },
|
||||
{ key: "DateLastContentAdded", value: "Content Added" },
|
||||
{ key: "DateCreated", value: "Date Added" },
|
||||
// Only works for shows (last episode added) keeping for future ref.
|
||||
// { key: "DateLastContentAdded", value: "Content Added" },
|
||||
{ key: "DatePlayed", value: "Date Played" },
|
||||
{ key: "PlayCount", value: "Play Count" },
|
||||
{ key: "ProductionYear", value: "Production Year" },
|
||||
@@ -23,7 +25,8 @@ export const sortOptions: {
|
||||
{ key: "StartDate", value: "Start Date" },
|
||||
{ key: "IsUnplayed", value: "Is Unplayed" },
|
||||
{ key: "IsPlayed", value: "Is Played" },
|
||||
{ key: "VideoBitRate", value: "Video Bit Rate" },
|
||||
// Broken in JF
|
||||
// { key: "VideoBitRate", value: "Video Bit Rate" },
|
||||
{ key: "AirTime", value: "Air Time" },
|
||||
{ key: "Studio", value: "Studio" },
|
||||
{ key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" },
|
||||
|
||||
@@ -2,6 +2,28 @@ import { atom, useAtom } from "jotai";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export type DownloadQuality = "original" | "high" | "low";
|
||||
|
||||
export type DownloadOption = {
|
||||
label: string;
|
||||
value: DownloadQuality;
|
||||
};
|
||||
|
||||
export const DownloadOptions: DownloadOption[] = [
|
||||
{
|
||||
label: "Original quality",
|
||||
value: "original",
|
||||
},
|
||||
{
|
||||
label: "High quality",
|
||||
value: "high",
|
||||
},
|
||||
{
|
||||
label: "Small file size",
|
||||
value: "low",
|
||||
},
|
||||
];
|
||||
|
||||
type Settings = {
|
||||
autoRotate?: boolean;
|
||||
forceLandscapeInVideoPlayer?: boolean;
|
||||
@@ -10,6 +32,10 @@ type Settings = {
|
||||
deviceProfile?: "Expo" | "Native" | "Old";
|
||||
forceDirectPlay?: boolean;
|
||||
mediaListCollectionIds?: string[];
|
||||
searchEngine: "Marlin" | "Jellyfin";
|
||||
marlinServerUrl?: string;
|
||||
openInVLC?: boolean;
|
||||
downloadQuality?: DownloadOption;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -20,20 +46,31 @@ type Settings = {
|
||||
*
|
||||
*/
|
||||
|
||||
// Utility function to load settings from AsyncStorage
|
||||
const loadSettings = async (): Promise<Settings> => {
|
||||
const jsonValue = await AsyncStorage.getItem("settings");
|
||||
return jsonValue != null
|
||||
? JSON.parse(jsonValue)
|
||||
: {
|
||||
autoRotate: true,
|
||||
forceLandscapeInVideoPlayer: false,
|
||||
openFullScreenVideoPlayerByDefault: false,
|
||||
usePopularPlugin: false,
|
||||
deviceProfile: "Expo",
|
||||
forceDirectPlay: false,
|
||||
mediaListCollectionIds: [],
|
||||
};
|
||||
const defaultValues: Settings = {
|
||||
autoRotate: true,
|
||||
forceLandscapeInVideoPlayer: false,
|
||||
openFullScreenVideoPlayerByDefault: false,
|
||||
usePopularPlugin: false,
|
||||
deviceProfile: "Expo",
|
||||
forceDirectPlay: false,
|
||||
mediaListCollectionIds: [],
|
||||
searchEngine: "Jellyfin",
|
||||
marlinServerUrl: "",
|
||||
openInVLC: false,
|
||||
downloadQuality: DownloadOptions[0],
|
||||
};
|
||||
|
||||
try {
|
||||
const jsonValue = await AsyncStorage.getItem("settings");
|
||||
const loadedValues: Partial<Settings> =
|
||||
jsonValue != null ? JSON.parse(jsonValue) : {};
|
||||
|
||||
return { ...defaultValues, ...loadedValues };
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
return defaultValues;
|
||||
}
|
||||
};
|
||||
|
||||
// Utility function to save settings to AsyncStorage
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -15,8 +15,8 @@ import { isBaseItemDto } from "../jellyfin";
|
||||
export const getPrimaryImageUrl = ({
|
||||
api,
|
||||
item,
|
||||
quality = 90,
|
||||
width = 500,
|
||||
quality = 80,
|
||||
width = 400,
|
||||
}: {
|
||||
api?: Api | null;
|
||||
item?: BaseItemDto | BaseItemPerson | null;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { AxiosError } from "axios";
|
||||
import { getAuthHeaders } from "../jellyfin";
|
||||
import { postCapabilities } from "../session/capabilities";
|
||||
|
||||
interface ReportPlaybackProgressParams {
|
||||
api: Api;
|
||||
sessionId: string;
|
||||
itemId: string;
|
||||
positionTicks: number;
|
||||
api?: Api | null;
|
||||
sessionId?: string | null;
|
||||
itemId?: string | null;
|
||||
positionTicks?: number | null;
|
||||
IsPaused?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,25 +21,44 @@ export const reportPlaybackProgress = async ({
|
||||
sessionId,
|
||||
itemId,
|
||||
positionTicks,
|
||||
IsPaused = false,
|
||||
}: ReportPlaybackProgressParams): Promise<void> => {
|
||||
console.info(
|
||||
"Reporting playback progress:",
|
||||
sessionId,
|
||||
itemId,
|
||||
positionTicks,
|
||||
);
|
||||
if (!api || !sessionId || !itemId || !positionTicks) {
|
||||
console.error(
|
||||
"Missing required parameter",
|
||||
sessionId,
|
||||
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 {
|
||||
await api.axiosInstance.post(
|
||||
`${api.basePath}/Sessions/Playing/Progress`,
|
||||
{
|
||||
ItemId: itemId,
|
||||
PlaySessionId: sessionId,
|
||||
IsPaused: false,
|
||||
IsPaused,
|
||||
PositionTicks: Math.round(positionTicks),
|
||||
CanSeek: true,
|
||||
MediaSourceId: itemId,
|
||||
EventName: "timeupdate",
|
||||
},
|
||||
{ headers: getAuthHeaders(api) },
|
||||
{ headers: getAuthHeaders(api) }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -41,12 +41,15 @@ export const reportPlaybackStopped = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("reportPlaybackStopped ~", { sessionId, itemId });
|
||||
|
||||
try {
|
||||
const url = `${api.basePath}/PlayingItems/${itemId}`;
|
||||
const params = {
|
||||
playSessionId: sessionId,
|
||||
positionTicks: Math.round(positionTicks),
|
||||
mediaSourceId: itemId,
|
||||
MediaSourceId: itemId,
|
||||
IsPaused: true,
|
||||
};
|
||||
const headers = getAuthHeaders(api);
|
||||
|
||||
@@ -58,7 +61,7 @@ export const reportPlaybackStopped = async ({
|
||||
console.error(
|
||||
"Failed to report playback progress",
|
||||
error.message,
|
||||
error.response?.data,
|
||||
error.response?.data
|
||||
);
|
||||
} else {
|
||||
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