mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-19 10:44:18 +01:00
feat: actor page
This commit is contained in:
@@ -11,6 +11,7 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
|
|
||||||
|
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";
|
||||||
@@ -37,8 +38,6 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { opacity } from "react-native-reanimated/lib/typescript/reanimated2/Colors";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,16 @@ export default function search() {
|
|||||||
enabled: debouncedSearch.length > 0,
|
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: () =>
|
||||||
@@ -205,13 +215,14 @@ export default function search() {
|
|||||||
movies?.length ||
|
movies?.length ||
|
||||||
episodes?.length ||
|
episodes?.length ||
|
||||||
series?.length ||
|
series?.length ||
|
||||||
collections?.length
|
collections?.length ||
|
||||||
|
actors?.length
|
||||||
);
|
);
|
||||||
}, [artists, episodes, albums, songs, movies, series, collections]);
|
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return l1 || l2 || l3 || l4 || l5 || l6 || l7;
|
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
|
||||||
}, [l1, l2, l3, l4, l5, l6, l7]);
|
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -327,6 +338,25 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<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;
|
||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Loader } from "../Loader";
|
|||||||
import { Text } from "./Text";
|
import { Text } from "./Text";
|
||||||
|
|
||||||
interface HorizontalScrollProps
|
interface HorizontalScrollProps
|
||||||
extends Omit<FlashListProps<BaseItemDto>, "renderItem" | "data"> {
|
extends Omit<FlashListProps<BaseItemDto>, "renderItem" | "data" | "style"> {
|
||||||
queryFn: ({
|
queryFn: ({
|
||||||
pageParam,
|
pageParam,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user