forked from Ninjalama/streamyfin_mirror
Compare commits
12 Commits
hotfix/lim
...
feat/on-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceb9969007 | ||
|
|
d330dd8db4 | ||
|
|
20739e6e2c | ||
|
|
ec50a90a32 | ||
|
|
6f6b46c14a | ||
|
|
7fcdfe9452 | ||
|
|
f9af493dc8 | ||
|
|
e8dc9e759a | ||
|
|
06877f4339 | ||
|
|
c496b1036b | ||
|
|
4cca6f0e8c | ||
|
|
7bf5fb9a01 |
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 21,
|
||||
"versionCode": 23,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon.png"
|
||||
},
|
||||
|
||||
@@ -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 { 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 {
|
||||
@@ -16,6 +28,7 @@ import {
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
@@ -24,26 +37,11 @@ import {
|
||||
getItemsApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { NativeScrollEvent, ScrollView, View } from "react-native";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
|
||||
const isCloseToBottom = ({
|
||||
layoutMeasurement,
|
||||
contentOffset,
|
||||
contentSize,
|
||||
}: NativeScrollEvent) => {
|
||||
const paddingToBottom = 200;
|
||||
return (
|
||||
layoutMeasurement.height + contentOffset.y >=
|
||||
contentSize.height - paddingToBottom
|
||||
);
|
||||
};
|
||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||
|
||||
const page: React.FC = () => {
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { libraryId } = searchParams as { libraryId: string };
|
||||
|
||||
@@ -61,6 +59,21 @@ const page: React.FC = () => {
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setSortBy([
|
||||
{
|
||||
key: "SortName",
|
||||
value: "Name",
|
||||
},
|
||||
]);
|
||||
setSortOrder([
|
||||
{
|
||||
key: "Ascending",
|
||||
value: "Ascending",
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||
(event) => {
|
||||
@@ -68,7 +81,6 @@ const page: React.FC = () => {
|
||||
}
|
||||
);
|
||||
|
||||
// Set the initial orientation
|
||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||
setOrientation(initialOrientation);
|
||||
});
|
||||
@@ -76,7 +88,7 @@ const page: React.FC = () => {
|
||||
return () => {
|
||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||
};
|
||||
}, [ScreenOrientation]);
|
||||
}, []);
|
||||
|
||||
const { data: library } = useQuery({
|
||||
queryKey: ["library", libraryId],
|
||||
@@ -86,8 +98,7 @@ const page: React.FC = () => {
|
||||
itemId: libraryId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
const data = response.data;
|
||||
return data;
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!libraryId,
|
||||
staleTime: 0,
|
||||
@@ -101,7 +112,7 @@ const page: React.FC = () => {
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !library) return null;
|
||||
|
||||
const includeItemTypes: BaseItemKind[] = [];
|
||||
let includeItemTypes: BaseItemKind[] | undefined = [];
|
||||
|
||||
switch (library?.CollectionType) {
|
||||
case "movies":
|
||||
@@ -117,13 +128,14 @@ const page: React.FC = () => {
|
||||
includeItemTypes.push("MusicAlbum");
|
||||
break;
|
||||
default:
|
||||
includeItemTypes = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
limit: 66,
|
||||
limit: 20,
|
||||
startIndex: pageParam,
|
||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0].key],
|
||||
@@ -152,10 +164,10 @@ const page: React.FC = () => {
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"library-items",
|
||||
library,
|
||||
libraryId,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
@@ -187,184 +199,236 @@ const page: React.FC = () => {
|
||||
enabled: !!api && !!user?.Id && !!library,
|
||||
});
|
||||
|
||||
const type = useMemo(() => {
|
||||
return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
|
||||
}, [data]);
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
return data?.pages.flatMap((p) => p?.Items) || [];
|
||||
return (
|
||||
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
||||
[]
|
||||
);
|
||||
}, [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 (
|
||||
<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"
|
||||
onScroll={({ nativeEvent }) => {
|
||||
if (isCloseToBottom(nativeEvent)) {
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={255}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
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={libraryId}
|
||||
queryKey="genreFilter"
|
||||
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>
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{ paddingBottom: 24 }}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
export default React.memo(Page);
|
||||
|
||||
@@ -157,6 +157,26 @@ export default function search() {
|
||||
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({
|
||||
queryKey: ["search", "artists", debouncedSearch],
|
||||
queryFn: () =>
|
||||
@@ -194,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 (
|
||||
<>
|
||||
@@ -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
|
||||
ids={artists?.map((m) => m.Id!)}
|
||||
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(() => {
|
||||
navigation.setOptions({
|
||||
title: albums?.Items?.[0].AlbumArtist,
|
||||
title: albums?.Items?.[0]?.AlbumArtist || "",
|
||||
});
|
||||
}, [albums]);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
@@ -24,23 +25,21 @@ import {
|
||||
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 React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { NativeScrollEvent, ScrollView, 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 isCloseToBottom = ({
|
||||
layoutMeasurement,
|
||||
contentOffset,
|
||||
contentSize,
|
||||
}: NativeScrollEvent) => {
|
||||
const paddingToBottom = 200;
|
||||
return (
|
||||
layoutMeasurement.height + contentOffset.y >=
|
||||
contentSize.height - paddingToBottom
|
||||
);
|
||||
};
|
||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -49,6 +48,9 @@ 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);
|
||||
@@ -56,7 +58,7 @@ const page: React.FC = () => {
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
setSortBy([
|
||||
{
|
||||
key: "PremiereDate",
|
||||
@@ -83,7 +85,7 @@ const page: React.FC = () => {
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!collectionId,
|
||||
staleTime: 0,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -130,7 +132,7 @@ const page: React.FC = () => {
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"collection-items",
|
||||
collection,
|
||||
@@ -165,178 +167,235 @@ const page: React.FC = () => {
|
||||
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(() => {
|
||||
return data?.pages.flatMap((p) => p?.Items) || [];
|
||||
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={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;
|
||||
|
||||
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"
|
||||
onScroll={({ nativeEvent }) => {
|
||||
if (isCloseToBottom(nativeEvent)) {
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={255}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
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>
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{ paddingBottom: 24 }}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -118,6 +118,13 @@ function Layout() {
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/actors/[actorId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/collections/[collectionId]"
|
||||
options={{
|
||||
|
||||
@@ -23,8 +23,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 176 * 2,
|
||||
quality: 80,
|
||||
width: 300,
|
||||
}),
|
||||
[item]
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
@@ -17,6 +17,14 @@ import Animated, {
|
||||
import Video from "react-native-video";
|
||||
import { Text } from "./common/Text";
|
||||
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 = () => {
|
||||
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(() => {
|
||||
if (segments.find((s) => s.includes("tabs"))) {
|
||||
// Tab screen - i.e. home
|
||||
@@ -136,7 +244,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
}
|
||||
`}
|
||||
>
|
||||
{currentlyPlaying?.url && (
|
||||
{streamUrl && (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
allowsExternalPlayback
|
||||
@@ -162,7 +270,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
fontSize: 16,
|
||||
}}
|
||||
source={{
|
||||
uri: currentlyPlaying.url,
|
||||
uri: streamUrl,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
|
||||
@@ -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,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,6 +45,10 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
router.push(`/artists/${item.Id}/page`);
|
||||
return;
|
||||
}
|
||||
if (item.Type === "Person") {
|
||||
router.push(`/actors/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "BoxSet") {
|
||||
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 { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { FilterSheet } from "./FilterSheet";
|
||||
|
||||
@@ -34,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
|
||||
@@ -52,6 +55,7 @@ export const FilterButton = <T,>({
|
||||
? "bg-purple-600 border border-purple-700"
|
||||
: "bg-neutral-900 border border-neutral-900"
|
||||
}
|
||||
${filters?.length === 0 && "opacity-50"}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -173,7 +173,7 @@ export const FilterSheet = <T,>({
|
||||
className="mb-4 flex flex-col rounded-xl overflow-hidden"
|
||||
>
|
||||
{renderData?.map((item, index) => (
|
||||
<>
|
||||
<View key={index}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!values.includes(item)) {
|
||||
@@ -183,7 +183,6 @@ export const FilterSheet = <T,>({
|
||||
}, 250);
|
||||
}
|
||||
}}
|
||||
key={`${index}`}
|
||||
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
|
||||
>
|
||||
<Text>{renderItemLabel(item)}</Text>
|
||||
@@ -199,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" />
|
||||
|
||||
@@ -123,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]);
|
||||
|
||||
|
||||
@@ -4,16 +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;
|
||||
@@ -35,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,10 +28,7 @@ export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (settings?.searchEngine === "Marlin")
|
||||
router.push(`/search?q=${item.Name}&prev=${pathname}`);
|
||||
else
|
||||
Linking.openURL(`https://www.google.com/search?q=${item.Name}`);
|
||||
router.push(`/actors/${item.Id}`);
|
||||
}}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-32"
|
||||
|
||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.8.1",
|
||||
"channel": "0.8.2",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.8.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"] });
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.
|
||||
@@ -30,6 +31,16 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
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}`;
|
||||
|
||||
// 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(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
|
||||
|
||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.8.1" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.8.2" },
|
||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user