mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-20 11:02:26 +00:00
Compare commits
16 Commits
v0.8.1
...
feat/on-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceb9969007 | ||
|
|
d330dd8db4 | ||
|
|
20739e6e2c | ||
|
|
ec50a90a32 | ||
|
|
6f6b46c14a | ||
|
|
7fcdfe9452 | ||
|
|
f9af493dc8 | ||
|
|
e8dc9e759a | ||
|
|
06877f4339 | ||
|
|
c496b1036b | ||
|
|
4cca6f0e8c | ||
|
|
7bf5fb9a01 | ||
|
|
bbf926e752 | ||
|
|
9b2a0487d2 | ||
|
|
a73488614c | ||
|
|
03fdf31b4b |
@@ -87,6 +87,12 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
|||||||
|
|
||||||
### Development info
|
### 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:
|
Add this to AppDelegate.mm:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.8.1",
|
"version": "0.8.2",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 21,
|
"versionCode": 23,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon.png"
|
"foregroundImage": "./assets/images/icon.png"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export default function index() {
|
|||||||
await getTvShowsApi(api).getNextUp({
|
await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount"],
|
fields: ["MediaSourceCount"],
|
||||||
|
limit: 20,
|
||||||
})
|
})
|
||||||
).data.Items) ||
|
).data.Items) ||
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
|
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 { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +28,7 @@ import {
|
|||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
import {
|
import {
|
||||||
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
@@ -24,26 +37,11 @@ import {
|
|||||||
getItemsApi,
|
getItemsApi,
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { NativeScrollEvent, ScrollView, View } from "react-native";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
|
|
||||||
const isCloseToBottom = ({
|
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||||
layoutMeasurement,
|
|
||||||
contentOffset,
|
|
||||||
contentSize,
|
|
||||||
}: NativeScrollEvent) => {
|
|
||||||
const paddingToBottom = 200;
|
|
||||||
return (
|
|
||||||
layoutMeasurement.height + contentOffset.y >=
|
|
||||||
contentSize.height - paddingToBottom
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
const { libraryId } = searchParams as { libraryId: string };
|
const { libraryId } = searchParams as { libraryId: string };
|
||||||
|
|
||||||
@@ -61,6 +59,21 @@ const page: React.FC = () => {
|
|||||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setSortBy([
|
||||||
|
{
|
||||||
|
key: "SortName",
|
||||||
|
value: "Name",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setSortOrder([
|
||||||
|
{
|
||||||
|
key: "Ascending",
|
||||||
|
value: "Ascending",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||||
(event) => {
|
(event) => {
|
||||||
@@ -68,7 +81,6 @@ const page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set the initial orientation
|
|
||||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||||
setOrientation(initialOrientation);
|
setOrientation(initialOrientation);
|
||||||
});
|
});
|
||||||
@@ -76,7 +88,7 @@ const page: React.FC = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||||
};
|
};
|
||||||
}, [ScreenOrientation]);
|
}, []);
|
||||||
|
|
||||||
const { data: library } = useQuery({
|
const { data: library } = useQuery({
|
||||||
queryKey: ["library", libraryId],
|
queryKey: ["library", libraryId],
|
||||||
@@ -86,8 +98,7 @@ const page: React.FC = () => {
|
|||||||
itemId: libraryId,
|
itemId: libraryId,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
});
|
});
|
||||||
const data = response.data;
|
return response.data;
|
||||||
return data;
|
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && !!libraryId,
|
enabled: !!api && !!user?.Id && !!libraryId,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
@@ -101,7 +112,7 @@ const page: React.FC = () => {
|
|||||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
if (!api || !library) return null;
|
if (!api || !library) return null;
|
||||||
|
|
||||||
const includeItemTypes: BaseItemKind[] = [];
|
let includeItemTypes: BaseItemKind[] | undefined = [];
|
||||||
|
|
||||||
switch (library?.CollectionType) {
|
switch (library?.CollectionType) {
|
||||||
case "movies":
|
case "movies":
|
||||||
@@ -117,13 +128,14 @@ const page: React.FC = () => {
|
|||||||
includeItemTypes.push("MusicAlbum");
|
includeItemTypes.push("MusicAlbum");
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
includeItemTypes = undefined;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
limit: 66,
|
limit: 20,
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||||
sortOrder: [sortOrder[0].key],
|
sortOrder: [sortOrder[0].key],
|
||||||
@@ -152,10 +164,10 @@ const page: React.FC = () => {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
|
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"library-items",
|
"library-items",
|
||||||
library,
|
libraryId,
|
||||||
selectedGenres,
|
selectedGenres,
|
||||||
selectedYears,
|
selectedYears,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
@@ -187,184 +199,236 @@ const page: React.FC = () => {
|
|||||||
enabled: !!api && !!user?.Id && !!library,
|
enabled: !!api && !!user?.Id && !!library,
|
||||||
});
|
});
|
||||||
|
|
||||||
const type = useMemo(() => {
|
|
||||||
return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const flatData = useMemo(() => {
|
const flatData = useMemo(() => {
|
||||||
return data?.pages.flatMap((p) => p?.Items) || [];
|
return (
|
||||||
|
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
||||||
|
[]
|
||||||
|
);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
if (!library || !library.CollectionType) return null;
|
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 (
|
return (
|
||||||
<ScrollView
|
<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"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
onScroll={({ nativeEvent }) => {
|
data={flatData}
|
||||||
if (isCloseToBottom(nativeEvent)) {
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
estimatedItemSize={255}
|
||||||
|
numColumns={
|
||||||
|
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||||
|
}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
scrollEventThrottle={400}
|
onEndReachedThreshold={0.5}
|
||||||
>
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
<View className="mt-4 mb-24">
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
<View className="mb-4">
|
ItemSeparatorComponent={() => (
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<View
|
||||||
<View className="flex flex-row space-x-1 px-3">
|
style={{
|
||||||
<ResetFiltersButton />
|
width: 10,
|
||||||
<FilterButton
|
height: 10,
|
||||||
collectionId={libraryId}
|
}}
|
||||||
queryKey="genreFilter"
|
></View>
|
||||||
queryFn={async () => {
|
)}
|
||||||
if (!api) return null;
|
/>
|
||||||
const response = await getFilterApi(
|
|
||||||
api
|
|
||||||
).getQueryFiltersLegacy({
|
|
||||||
userId: user?.Id,
|
|
||||||
includeItemTypes: type ? [type] : [],
|
|
||||||
parentId: libraryId,
|
|
||||||
});
|
|
||||||
return response.data.Genres || [];
|
|
||||||
}}
|
|
||||||
set={setSelectedGenres}
|
|
||||||
values={selectedGenres}
|
|
||||||
title="Genres"
|
|
||||||
renderItemLabel={(item) => item.toString()}
|
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<FilterButton
|
|
||||||
collectionId={libraryId}
|
|
||||||
queryKey="tagsFilter"
|
|
||||||
queryFn={async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getFilterApi(
|
|
||||||
api
|
|
||||||
).getQueryFiltersLegacy({
|
|
||||||
userId: user?.Id,
|
|
||||||
includeItemTypes: type ? [type] : [],
|
|
||||||
parentId: libraryId,
|
|
||||||
});
|
|
||||||
return response.data.Tags || [];
|
|
||||||
}}
|
|
||||||
set={setSelectedTags}
|
|
||||||
values={selectedTags}
|
|
||||||
title="Tags"
|
|
||||||
renderItemLabel={(item) => item.toString()}
|
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<FilterButton
|
|
||||||
collectionId={libraryId}
|
|
||||||
queryKey="yearFilter"
|
|
||||||
queryFn={async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getFilterApi(
|
|
||||||
api
|
|
||||||
).getQueryFiltersLegacy({
|
|
||||||
userId: user?.Id,
|
|
||||||
includeItemTypes: type ? [type] : [],
|
|
||||||
parentId: libraryId,
|
|
||||||
});
|
|
||||||
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={libraryId}
|
|
||||||
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={libraryId}
|
|
||||||
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}-${index}`}
|
|
||||||
style={{
|
|
||||||
width:
|
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
|
|
||||||
? "32%"
|
|
||||||
: "20%",
|
|
||||||
marginBottom:
|
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
|
|
||||||
? 4
|
|
||||||
: 16,
|
|
||||||
}}
|
|
||||||
item={item}
|
|
||||||
className={`
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{flatData.length % 3 !== 0 && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width:
|
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
|
|
||||||
? "32%"
|
|
||||||
: "20%",
|
|
||||||
}}
|
|
||||||
></View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default page;
|
export default React.memo(Page);
|
||||||
|
|||||||
@@ -157,6 +157,26 @@ export default function search() {
|
|||||||
enabled: debouncedSearch.length > 0,
|
enabled: debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: collections, isFetching: l7 } = useQuery({
|
||||||
|
queryKey: ["search", "collections", debouncedSearch],
|
||||||
|
queryFn: () =>
|
||||||
|
searchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
|
types: ["BoxSet"],
|
||||||
|
}),
|
||||||
|
enabled: debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: actors, isFetching: l8 } = useQuery({
|
||||||
|
queryKey: ["search", "actors", debouncedSearch],
|
||||||
|
queryFn: () =>
|
||||||
|
searchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
|
types: ["Person"],
|
||||||
|
}),
|
||||||
|
enabled: debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: artists, isFetching: l4 } = useQuery({
|
const { data: artists, isFetching: l4 } = useQuery({
|
||||||
queryKey: ["search", "artists", debouncedSearch],
|
queryKey: ["search", "artists", debouncedSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -194,13 +214,15 @@ export default function search() {
|
|||||||
songs?.length ||
|
songs?.length ||
|
||||||
movies?.length ||
|
movies?.length ||
|
||||||
episodes?.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(() => {
|
const loading = useMemo(() => {
|
||||||
return l1 || l2 || l3 || l4 || l5 || l6;
|
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
|
||||||
}, [l1, l2, l3, l4, l5, l6]);
|
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -295,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
|
<SearchItemWrapper
|
||||||
ids={artists?.map((m) => m.Id!)}
|
ids={artists?.map((m) => m.Id!)}
|
||||||
header="Artists"
|
header="Artists"
|
||||||
|
|||||||
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(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
title: albums?.Items?.[0].AlbumArtist,
|
title: albums?.Items?.[0]?.AlbumArtist || "",
|
||||||
});
|
});
|
||||||
}, [albums]);
|
}, [albums]);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
import {
|
import {
|
||||||
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
@@ -24,23 +25,21 @@ import {
|
|||||||
getItemsApi,
|
getItemsApi,
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
|
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo } from "react";
|
import React, {
|
||||||
import { NativeScrollEvent, ScrollView, View } from "react-native";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { FlatList, NativeScrollEvent, ScrollView, View } from "react-native";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
|
|
||||||
const isCloseToBottom = ({
|
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||||
layoutMeasurement,
|
|
||||||
contentOffset,
|
|
||||||
contentSize,
|
|
||||||
}: NativeScrollEvent) => {
|
|
||||||
const paddingToBottom = 200;
|
|
||||||
return (
|
|
||||||
layoutMeasurement.height + contentOffset.y >=
|
|
||||||
contentSize.height - paddingToBottom
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -49,6 +48,9 @@ const page: React.FC = () => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const [orientation, setOrientation] = useState(
|
||||||
|
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
|
);
|
||||||
|
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
@@ -56,7 +58,7 @@ const page: React.FC = () => {
|
|||||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setSortBy([
|
setSortBy([
|
||||||
{
|
{
|
||||||
key: "PremiereDate",
|
key: "PremiereDate",
|
||||||
@@ -83,7 +85,7 @@ const page: React.FC = () => {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && !!collectionId,
|
enabled: !!api && !!user?.Id && !!collectionId,
|
||||||
staleTime: 0,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -130,7 +132,7 @@ const page: React.FC = () => {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
|
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"collection-items",
|
"collection-items",
|
||||||
collection,
|
collection,
|
||||||
@@ -165,178 +167,235 @@ const page: React.FC = () => {
|
|||||||
enabled: !!api && !!user?.Id && !!collection,
|
enabled: !!api && !!user?.Id && !!collection,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log("Data: ", data);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const type = useMemo(() => {
|
|
||||||
return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const flatData = useMemo(() => {
|
const flatData = useMemo(() => {
|
||||||
return data?.pages.flatMap((p) => p?.Items) || [];
|
return (
|
||||||
|
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
||||||
|
[]
|
||||||
|
);
|
||||||
}, [data]);
|
}, [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={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())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
collectionId,
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
selectedGenres,
|
||||||
|
setSelectedGenres,
|
||||||
|
selectedYears,
|
||||||
|
setSelectedYears,
|
||||||
|
selectedTags,
|
||||||
|
setSelectedTags,
|
||||||
|
sortBy,
|
||||||
|
setSortBy,
|
||||||
|
sortOrder,
|
||||||
|
setSortOrder,
|
||||||
|
isFetching,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<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"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
onScroll={({ nativeEvent }) => {
|
data={flatData}
|
||||||
if (isCloseToBottom(nativeEvent)) {
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
estimatedItemSize={255}
|
||||||
|
numColumns={
|
||||||
|
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||||
|
}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
scrollEventThrottle={400}
|
onEndReachedThreshold={0.5}
|
||||||
>
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
<View className="mt-4 mb-24">
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
<View className="mb-4">
|
ItemSeparatorComponent={() => (
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<View
|
||||||
<View className="flex flex-row space-x-1 px-3">
|
style={{
|
||||||
<ResetFiltersButton />
|
width: 10,
|
||||||
<FilterButton
|
height: 10,
|
||||||
collectionId={collectionId}
|
}}
|
||||||
queryKey="genreFilter"
|
></View>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,13 @@ function Layout() {
|
|||||||
headerShown: false,
|
headerShown: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(auth)/actors/[actorId]"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(auth)/collections/[collectionId]"
|
name="(auth)/collections/[collectionId]"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
getPrimaryImageUrl({
|
getPrimaryImageUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
quality: 90,
|
quality: 80,
|
||||||
width: 176 * 2,
|
width: 300,
|
||||||
}),
|
}),
|
||||||
[item]
|
[item]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
@@ -17,6 +17,14 @@ import Animated, {
|
|||||||
import Video from "react-native-video";
|
import Video from "react-native-video";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import {
|
||||||
|
FFmpegKit,
|
||||||
|
FFmpegKitConfig,
|
||||||
|
FFmpegSession,
|
||||||
|
ReturnCode,
|
||||||
|
} from "ffmpeg-kit-react-native";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
export const CurrentlyPlayingBar: React.FC = () => {
|
export const CurrentlyPlayingBar: React.FC = () => {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
@@ -63,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(() => {
|
useEffect(() => {
|
||||||
if (segments.find((s) => s.includes("tabs"))) {
|
if (segments.find((s) => s.includes("tabs"))) {
|
||||||
// Tab screen - i.e. home
|
// Tab screen - i.e. home
|
||||||
@@ -136,7 +244,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{currentlyPlaying?.url && (
|
{streamUrl && (
|
||||||
<Video
|
<Video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
allowsExternalPlayback
|
allowsExternalPlayback
|
||||||
@@ -162,7 +270,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
}}
|
}}
|
||||||
source={{
|
source={{
|
||||||
uri: currentlyPlaying.url,
|
uri: streamUrl,
|
||||||
isNetwork: true,
|
isNetwork: true,
|
||||||
startPosition,
|
startPosition,
|
||||||
headers: getAuthHeaders(api),
|
headers: getAuthHeaders(api),
|
||||||
|
|||||||
@@ -5,26 +5,32 @@ import { useState } from "react";
|
|||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
|
characterLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIMIT = 140;
|
export const OverviewText: React.FC<Props> = ({
|
||||||
|
text,
|
||||||
export const OverviewText: React.FC<Props> = ({ text, ...props }) => {
|
characterLimit = 140,
|
||||||
const [limit, setLimit] = useState(LIMIT);
|
...props
|
||||||
|
}) => {
|
||||||
|
const [limit, setLimit] = useState(characterLimit);
|
||||||
|
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
if (text.length > LIMIT)
|
if (text.length > characterLimit)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
setLimit((prev) => (prev === LIMIT ? text.length : LIMIT))
|
setLimit((prev) =>
|
||||||
|
prev === characterLimit ? text.length : characterLimit
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<View {...props} className="">
|
<View {...props} className="">
|
||||||
<Text>{tc(text, limit)}</Text>
|
<Text>{tc(text, limit)}</Text>
|
||||||
<Text className="text-purple-600 mt-1">
|
<Text className="text-purple-600 mt-1">
|
||||||
{limit === LIMIT ? "Show more" : "Show less"}
|
{limit === characterLimit ? "Show more" : "Show less"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { FlashList, FlashListProps } from "@shopify/flash-list";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { ScrollView, ScrollViewProps, View, ViewStyle } from "react-native";
|
import { View, ViewStyle } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -8,7 +9,13 @@ import Animated, {
|
|||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Text } from "./Text";
|
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;
|
data?: T[] | null;
|
||||||
renderItem: (item: T, index: number) => React.ReactNode;
|
renderItem: (item: T, index: number) => React.ReactNode;
|
||||||
containerStyle?: ViewStyle;
|
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 (
|
return (
|
||||||
<ScrollView
|
<Animated.View style={[containerStyle, animatedStyle1]}>
|
||||||
horizontal
|
<FlashList
|
||||||
style={containerStyle}
|
data={data}
|
||||||
contentContainerStyle={contentContainerStyle}
|
renderItem={renderFlashListItem}
|
||||||
showsHorizontalScrollIndicator={false}
|
horizontal
|
||||||
{...props}
|
estimatedItemSize={100}
|
||||||
>
|
showsHorizontalScrollIndicator={false}
|
||||||
<Animated.View
|
contentContainerStyle={{
|
||||||
className={`
|
paddingHorizontal: 16,
|
||||||
flex flex-row px-4
|
...contentContainerStyle,
|
||||||
`}
|
}}
|
||||||
style={[animatedStyle1]}
|
ListEmptyComponent={() => (
|
||||||
>
|
|
||||||
{data.map((item, index) => (
|
|
||||||
<View className="mr-2" key={index}>
|
|
||||||
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
{data.length === 0 && (
|
|
||||||
<View className="flex-1 justify-center items-center">
|
<View className="flex-1 justify-center items-center">
|
||||||
<Text className="text-center text-gray-500">No data available</Text>
|
<Text className="text-center text-gray-500">No data available</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
{...props}
|
||||||
</ScrollView>
|
/>
|
||||||
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useEffect } from "react";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
NativeScrollEvent,
|
BaseItemDto,
|
||||||
ScrollView,
|
BaseItemDtoQueryResult,
|
||||||
ScrollViewProps,
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
View,
|
import { FlashList, FlashListProps } from "@shopify/flash-list";
|
||||||
ViewStyle,
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
} from "react-native";
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useEffect, useMemo } from "react";
|
||||||
|
import { View, ViewStyle } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -13,16 +15,9 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Text } from "./Text";
|
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: ({
|
queryFn: ({
|
||||||
pageParam,
|
pageParam,
|
||||||
}: {
|
}: {
|
||||||
@@ -38,18 +33,6 @@ interface HorizontalScrollProps extends ScrollViewProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCloseToBottom = ({
|
|
||||||
layoutMeasurement,
|
|
||||||
contentOffset,
|
|
||||||
contentSize,
|
|
||||||
}: NativeScrollEvent) => {
|
|
||||||
const paddingToBottom = 50;
|
|
||||||
return (
|
|
||||||
layoutMeasurement.height + contentOffset.y >=
|
|
||||||
contentSize.height - paddingToBottom
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function InfiniteHorizontalScroll({
|
export function InfiniteHorizontalScroll({
|
||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
@@ -64,7 +47,6 @@ export function InfiniteHorizontalScroll({
|
|||||||
}: HorizontalScrollProps): React.ReactElement {
|
}: HorizontalScrollProps): React.ReactElement {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const animatedOpacity = useSharedValue(0);
|
const animatedOpacity = useSharedValue(0);
|
||||||
const animatedStyle1 = useAnimatedStyle(() => {
|
const animatedStyle1 = useAnimatedStyle(() => {
|
||||||
@@ -73,7 +55,7 @@ export function InfiniteHorizontalScroll({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
|
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
getNextPageParam: (lastPage, pages) => {
|
getNextPageParam: (lastPage, pages) => {
|
||||||
@@ -100,6 +82,13 @@ export function InfiniteHorizontalScroll({
|
|||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const flatData = useMemo(() => {
|
||||||
|
return (
|
||||||
|
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
animatedOpacity.value = 1;
|
animatedOpacity.value = 1;
|
||||||
@@ -124,41 +113,34 @@ export function InfiniteHorizontalScroll({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<Animated.View style={[containerStyle, animatedStyle1]}>
|
||||||
horizontal
|
<FlashList
|
||||||
onScroll={({ nativeEvent }) => {
|
data={flatData}
|
||||||
if (isCloseToBottom(nativeEvent)) {
|
renderItem={({ item, index }) => (
|
||||||
fetchNextPage();
|
<View className="mr-2">
|
||||||
}
|
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||||
}}
|
</View>
|
||||||
scrollEventThrottle={400}
|
)}
|
||||||
style={containerStyle}
|
estimatedItemSize={height}
|
||||||
contentContainerStyle={contentContainerStyle}
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
onEndReached={() => {
|
||||||
{...props}
|
if (hasNextPage) {
|
||||||
>
|
fetchNextPage();
|
||||||
<Animated.View
|
}
|
||||||
className={`
|
}}
|
||||||
flex flex-row px-4
|
onEndReachedThreshold={0.5}
|
||||||
`}
|
contentContainerStyle={{
|
||||||
style={[animatedStyle1]}
|
paddingHorizontal: 16,
|
||||||
>
|
...contentContainerStyle,
|
||||||
{data?.pages
|
}}
|
||||||
.flatMap((page) => page?.Items)
|
showsHorizontalScrollIndicator={false}
|
||||||
.map(
|
ListEmptyComponent={
|
||||||
(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 && (
|
|
||||||
<View className="flex-1 justify-center items-center">
|
<View className="flex-1 justify-center items-center">
|
||||||
<Text className="text-center text-gray-500">No data available</Text>
|
<Text className="text-center text-gray-500">No data available</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
}
|
||||||
</Animated.View>
|
{...props}
|
||||||
</ScrollView>
|
/>
|
||||||
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
router.push(`/artists/${item.Id}/page`);
|
router.push(`/artists/${item.Id}/page`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (item.Type === "Person") {
|
||||||
|
router.push(`/actors/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.Type === "BoxSet") {
|
if (item.Type === "BoxSet") {
|
||||||
router.push(`/collections/${item.Id}`);
|
router.push(`/collections/${item.Id}`);
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { FilterSheet } from "./FilterSheet";
|
import { FilterSheet } from "./FilterSheet";
|
||||||
|
|
||||||
@@ -34,16 +34,19 @@ export const FilterButton = <T,>({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { data: filters } = useQuery<T[]>({
|
const { data: filters } = useQuery<T[]>({
|
||||||
queryKey: [queryKey, collectionId],
|
queryKey: ["filters", title, queryKey, collectionId],
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
|
enabled: !!collectionId && !!queryFn && !!queryKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filters?.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TouchableOpacity onPress={() => setOpen(true)}>
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
filters?.length && setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
||||||
@@ -52,6 +55,7 @@ export const FilterButton = <T,>({
|
|||||||
? "bg-purple-600 border border-purple-700"
|
? "bg-purple-600 border border-purple-700"
|
||||||
: "bg-neutral-900 border border-neutral-900"
|
: "bg-neutral-900 border border-neutral-900"
|
||||||
}
|
}
|
||||||
|
${filters?.length === 0 && "opacity-50"}
|
||||||
`}
|
`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export const FilterSheet = <T,>({
|
|||||||
className="mb-4 flex flex-col rounded-xl overflow-hidden"
|
className="mb-4 flex flex-col rounded-xl overflow-hidden"
|
||||||
>
|
>
|
||||||
{renderData?.map((item, index) => (
|
{renderData?.map((item, index) => (
|
||||||
<>
|
<View key={index}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!values.includes(item)) {
|
if (!values.includes(item)) {
|
||||||
@@ -183,7 +183,6 @@ export const FilterSheet = <T,>({
|
|||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
key={`${index}`}
|
|
||||||
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
|
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
|
||||||
>
|
>
|
||||||
<Text>{renderItemLabel(item)}</Text>
|
<Text>{renderItemLabel(item)}</Text>
|
||||||
@@ -199,7 +198,7 @@ export const FilterSheet = <T,>({
|
|||||||
}}
|
}}
|
||||||
className="h-1 divide-neutral-700 "
|
className="h-1 divide-neutral-700 "
|
||||||
></View>
|
></View>
|
||||||
</>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
{data.length < (_data?.length || 0) && (
|
{data.length < (_data?.length || 0) && (
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
|||||||
setSelectedTags([]);
|
setSelectedTags([]);
|
||||||
setSelectedYears([]);
|
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}
|
{...props}
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={20} color="white" />
|
<Ionicons name="close" size={20} color="white" />
|
||||||
|
|||||||
@@ -123,14 +123,16 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
|||||||
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const screenWidth = Dimensions.get("screen").width;
|
||||||
|
|
||||||
const uri = useMemo(() => {
|
const uri = useMemo(() => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
|
|
||||||
return getBackdropUrl({
|
return getBackdropUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
quality: 90,
|
quality: 70,
|
||||||
width: 1000,
|
width: Math.floor(screenWidth * 0.8 * 2),
|
||||||
});
|
});
|
||||||
}, [api, item]);
|
}, [api, item]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,14 @@ import {
|
|||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtom } from "jotai";
|
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 { 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 { ItemCardText } from "../ItemCardText";
|
||||||
|
import MoviePoster from "../posters/MoviePoster";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
collection: BaseItemDto;
|
collection: BaseItemDto;
|
||||||
@@ -35,7 +33,7 @@ export const MediaListSection: React.FC<Props> = ({ collection, ...props }) => {
|
|||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
parentId: collection.Id,
|
parentId: collection.Id,
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
limit: 10,
|
limit: 8,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -10,12 +10,8 @@ interface Props extends ViewProps {
|
|||||||
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<>
|
<View className="flex flex-row items-center self-center px-4" {...props}>
|
||||||
<View className="flex flex-row items-center self-center px-4">
|
<Text className="text-center font-bold text-2xl mr-2">{item?.Name}</Text>
|
||||||
<Text className="text-center font-bold text-2xl mr-2">
|
</View>
|
||||||
{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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import ArtistPoster from "../ArtistPoster";
|
|
||||||
import { runtimeTicksToMinutes, runtimeTicksToSeconds } from "@/utils/time";
|
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
|
import { View, ViewProps } from "react-native";
|
||||||
import { SongsListItem } from "./SongsListItem";
|
import { SongsListItem } from "./SongsListItem";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
|
|||||||
@@ -1,27 +1,20 @@
|
|||||||
import {
|
|
||||||
TouchableOpacity,
|
|
||||||
TouchableOpacityProps,
|
|
||||||
View,
|
|
||||||
ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
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 { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
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 { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useCastDevice,
|
useCastDevice,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} 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 {
|
interface Props extends TouchableOpacityProps {
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
@@ -42,12 +35,12 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const castDevice = useCastDevice();
|
const castDevice = useCastDevice();
|
||||||
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
|
||||||
|
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
|
const { setCurrentlyPlayingState } = usePlayback();
|
||||||
|
|
||||||
const openSelect = () => {
|
const openSelect = () => {
|
||||||
if (!castDevice?.deviceId) {
|
if (!castDevice?.deviceId) {
|
||||||
play("device");
|
play("device");
|
||||||
@@ -73,7 +66,7 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,11 +111,10 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setCp({
|
setCurrentlyPlayingState({
|
||||||
item,
|
item,
|
||||||
playbackUrl: url,
|
url,
|
||||||
});
|
});
|
||||||
setPlaying(true);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const AlbumCover: React.FC<ArtistPosterProps> = ({ item, id }) => {
|
|||||||
|
|
||||||
if (!item && id)
|
if (!item && id)
|
||||||
return (
|
return (
|
||||||
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
||||||
<Image
|
<Image
|
||||||
key={id}
|
key={id}
|
||||||
id={id}
|
id={id}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const ArtistPoster: React.FC<ArtistPosterProps> = ({
|
|||||||
if (!url)
|
if (!url)
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="rounded-md overflow-hidden border border-neutral-900"
|
className="rounded-lg overflow-hidden border border-neutral-900"
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: "1/1",
|
aspectRatio: "1/1",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
|||||||
getPrimaryImageUrl({
|
getPrimaryImageUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
|
width: 300,
|
||||||
}),
|
}),
|
||||||
[item]
|
[item]
|
||||||
);
|
);
|
||||||
@@ -37,7 +38,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
|||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
||||||
<Image
|
<Image
|
||||||
placeholder={{
|
placeholder={{
|
||||||
blurhash,
|
blurhash,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const ParentPoster: React.FC<PosterProps> = ({ id }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="rounded-md overflow-hidden border border-neutral-900">
|
<View className="rounded-lg overflow-hidden border border-neutral-900">
|
||||||
<Image
|
<Image
|
||||||
key={id}
|
key={id}
|
||||||
id={id}
|
id={id}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="rounded-md overflow-hidden border border-neutral-900">
|
<View className="rounded-lg overflow-hidden border border-neutral-900">
|
||||||
<Image
|
<Image
|
||||||
placeholder={
|
placeholder={
|
||||||
blurhash
|
blurhash
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
|||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
||||||
<Image
|
<Image
|
||||||
placeholder={{
|
placeholder={{
|
||||||
blurhash,
|
blurhash,
|
||||||
|
|||||||
@@ -28,10 +28,7 @@ export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
|||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (settings?.searchEngine === "Marlin")
|
router.push(`/actors/${item.Id}`);
|
||||||
router.push(`/search?q=${item.Name}&prev=${pathname}`);
|
|
||||||
else
|
|
||||||
Linking.openURL(`https://www.google.com/search?q=${item.Name}`);
|
|
||||||
}}
|
}}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-32"
|
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 { 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 React from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import Poster from "../posters/Poster";
|
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
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 }) => {
|
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -26,6 +24,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
seriesId,
|
seriesId,
|
||||||
fields: ["MediaSourceCount"],
|
fields: ["MediaSourceCount"],
|
||||||
|
limit: 10,
|
||||||
})
|
})
|
||||||
).data.Items;
|
).data.Items;
|
||||||
},
|
},
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.8.1",
|
"channel": "0.8.2",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.8.1",
|
"channel": "0.8.2",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -26,11 +26,12 @@ export const useFiles = () => {
|
|||||||
fileNames.map((item) =>
|
fileNames.map((item) =>
|
||||||
FileSystem.deleteAsync(`${directoryUri}/${item}`, {
|
FileSystem.deleteAsync(`${directoryUri}/${item}`, {
|
||||||
idempotent: true,
|
idempotent: true,
|
||||||
}),
|
})
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
await AsyncStorage.removeItem("downloaded_files");
|
await AsyncStorage.removeItem("downloaded_files");
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["downloaded"] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete all files:", error);
|
console.error("Failed to delete all files:", error);
|
||||||
}
|
}
|
||||||
@@ -49,7 +50,7 @@ export const useFiles = () => {
|
|||||||
try {
|
try {
|
||||||
await FileSystem.deleteAsync(
|
await FileSystem.deleteAsync(
|
||||||
`${FileSystem.documentDirectory}/${id}.mp4`,
|
`${FileSystem.documentDirectory}/${id}.mp4`,
|
||||||
{ idempotent: true },
|
{ idempotent: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentFiles = await getDownloadedFiles();
|
const currentFiles = await getDownloadedFiles();
|
||||||
@@ -57,7 +58,7 @@ export const useFiles = () => {
|
|||||||
|
|
||||||
await AsyncStorage.setItem(
|
await AsyncStorage.setItem(
|
||||||
"downloaded_files",
|
"downloaded_files",
|
||||||
JSON.stringify(updatedFiles),
|
JSON.stringify(updatedFiles)
|
||||||
);
|
);
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||||
@@ -30,6 +31,16 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
|||||||
async (url: string) => {
|
async (url: string) => {
|
||||||
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||||
|
|
||||||
|
// let command: string | null = null;
|
||||||
|
|
||||||
|
// if (Platform.OS === "android") {
|
||||||
|
// command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c:v h264_mediacodec -c:a copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||||
|
// } else if (Platform.OS === "ios") {
|
||||||
|
// command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c:v h264_videotoolbox -c:a copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||||
|
// } else {
|
||||||
|
// throw new Error("Unsupported platform");
|
||||||
|
// }
|
||||||
|
|
||||||
writeToLog(
|
writeToLog(
|
||||||
"INFO",
|
"INFO",
|
||||||
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
|
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.8.1" },
|
clientInfo: { name: "Streamyfin", version: "0.8.2" },
|
||||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import { isBaseItemDto } from "../jellyfin";
|
|||||||
export const getPrimaryImageUrl = ({
|
export const getPrimaryImageUrl = ({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
quality = 90,
|
quality = 80,
|
||||||
width = 500,
|
width = 400,
|
||||||
}: {
|
}: {
|
||||||
api?: Api | null;
|
api?: Api | null;
|
||||||
item?: BaseItemDto | BaseItemPerson | null;
|
item?: BaseItemDto | BaseItemPerson | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user