forked from Ninjalama/streamyfin_mirror
fix: better posters and item screen
This commit is contained in:
@@ -8,7 +8,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, Stack } from "expo-router";
|
||||
import { router } from "expo-router";
|
||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
@@ -70,7 +70,9 @@ const downloads: React.FC = () => {
|
||||
<View className="flex flex-col space-y-2">
|
||||
{queue.map((q) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(auth)/items/${q.item.Id}`)}
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||
}
|
||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||
>
|
||||
<View>
|
||||
@@ -97,7 +99,9 @@ const downloads: React.FC = () => {
|
||||
<Text className="text-2xl font-bold mb-2">Active download</Text>
|
||||
{process?.item ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(auth)/items/${process.item.Id}`)}
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/page?id=${process.item.Id}`)
|
||||
}
|
||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||
>
|
||||
<View>
|
||||
|
||||
13
app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx
Normal file
13
app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
|
||||
const memoizedContent = useMemo(() => <ItemContent id={id} />, [id]);
|
||||
|
||||
return memoizedContent;
|
||||
};
|
||||
|
||||
export default React.memo(Page);
|
||||
@@ -61,6 +61,7 @@ const page: React.FC = () => {
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerHeight={300}
|
||||
headerImage={
|
||||
<Image
|
||||
source={{
|
||||
|
||||
@@ -245,7 +245,7 @@ export default function search() {
|
||||
header="Movies"
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
@@ -269,7 +269,7 @@ export default function search() {
|
||||
ids={series?.map((m) => m.Id!)}
|
||||
header="Series"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
@@ -293,12 +293,12 @@ export default function search() {
|
||||
ids={episodes?.map((m) => m.Id!)}
|
||||
header="Episodes"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/items/${item.Id}`)}
|
||||
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
||||
className="flex flex-col w-44"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
@@ -312,7 +312,7 @@ export default function search() {
|
||||
ids={collections?.map((m) => m.Id!)}
|
||||
header="Collections"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
@@ -333,7 +333,7 @@ export default function search() {
|
||||
ids={actors?.map((m) => m.Id!)}
|
||||
header="Actors"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
@@ -352,7 +352,7 @@ export default function search() {
|
||||
ids={artists?.map((m) => m.Id!)}
|
||||
header="Artists"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
@@ -371,7 +371,7 @@ export default function search() {
|
||||
ids={albums?.map((m) => m.Id!)}
|
||||
header="Albums"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
@@ -390,7 +390,7 @@ export default function search() {
|
||||
ids={songs?.map((m) => m.Id!)}
|
||||
header="Songs"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
|
||||
@@ -39,7 +39,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
<View className="flex flex-row items-center justify-between" {...props}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<View className="flex flex-col">
|
||||
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
|
||||
@@ -54,7 +54,7 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
<View className="flex flex-row items-center justify-between" {...props}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<View className="flex flex-col">
|
||||
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
|
||||
@@ -231,7 +231,8 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
onPress={() => {
|
||||
if (currentlyPlaying.item?.Type === "Audio")
|
||||
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
||||
else router.push(`/items/${currentlyPlaying.item?.Id}`);
|
||||
else
|
||||
router.push(`/items/page?id=${currentlyPlaying.item?.Id}`);
|
||||
}}
|
||||
>
|
||||
<Text>{currentlyPlaying.item?.Name}</Text>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
@@ -9,18 +8,16 @@ import { PlayedStatus } from "@/components/PlayedStatus";
|
||||
import { Ratings } from "@/components/Ratings";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import { EpisodeTitleHeader } from "@/components/series/EpisodeTitleHeader";
|
||||
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 { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryParentImageUrl } from "@/utils/jellyfin/image/getPrimaryParentImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
@@ -30,21 +27,17 @@ import old from "@/utils/profiles/old";
|
||||
import { 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 { useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useCastDevice } from "react-native-google-cast";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
const { id } = local as { id: string };
|
||||
|
||||
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [settings] = useSettings();
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
@@ -55,7 +48,11 @@ const page: React.FC = () => {
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
const { data: item, isLoading: l1 } = useQuery({
|
||||
const {
|
||||
data: item,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
@@ -127,63 +124,36 @@ const page: React.FC = () => {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const itemBackdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 95,
|
||||
width: 1200,
|
||||
}),
|
||||
[item]
|
||||
);
|
||||
|
||||
const seriesBackdropUrl = useMemo(
|
||||
() =>
|
||||
getParentBackdropImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 95,
|
||||
width: 1200,
|
||||
}),
|
||||
[item]
|
||||
);
|
||||
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
|
||||
const episodePoster = useMemo(
|
||||
() =>
|
||||
item?.Type === "Episode"
|
||||
? `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`
|
||||
: null,
|
||||
[item]
|
||||
const loading = useMemo(
|
||||
() => isLoading || isFetching,
|
||||
[isLoading, isFetching]
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!item?.Id) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerHeight={item.Type === "Episode" ? 300 : 400}
|
||||
headerHeight={300}
|
||||
headerImage={
|
||||
<>
|
||||
{itemBackdropUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: itemBackdropUrl,
|
||||
}}
|
||||
{item ? (
|
||||
<ItemImage
|
||||
variant={item.Type === "Movie" ? "Backdrop" : "Primary"}
|
||||
item={item}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
logo={
|
||||
@@ -203,62 +173,65 @@ const page: React.FC = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col px-4 pt-4">
|
||||
<View className="flex flex-col">
|
||||
{item.Type === "Episode" ? (
|
||||
<SeriesTitleHeader item={item} />
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-col px-4 w-full space-y-1 pt-4">
|
||||
<ItemHeader item={item} />
|
||||
|
||||
{item ? (
|
||||
<View className="flex flex-row justify-between items-center mb-2">
|
||||
<DownloadItem item={item} />
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<MoviesTitleHeader item={item} />
|
||||
</>
|
||||
<View>
|
||||
<View className="bg-neutral-950 h-8 w-full rounded-lg my-2"></View>
|
||||
</View>
|
||||
)}
|
||||
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
||||
<Ratings item={item} />
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row justify-between items-center mb-2">
|
||||
{playbackUrl ? (
|
||||
<DownloadItem item={item} />
|
||||
{item ? (
|
||||
<View className="flex flex-row items-center space-x-2 w-full mb-1">
|
||||
<BitrateSelector
|
||||
onChange={(val) => setMaxBitrate(val)}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
{item && (
|
||||
<AudioTrackSelector
|
||||
item={item}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
)}
|
||||
{item && (
|
||||
<SubtitleTrackSelector
|
||||
item={item}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
||||
<View className="mb-1">
|
||||
<View className="bg-neutral-950 h-4 w-2/4 rounded-md mb-1"></View>
|
||||
<View className="bg-neutral-950 h-10 w-3/4 rounded-lg"></View>
|
||||
</View>
|
||||
)}
|
||||
<PlayedStatus item={item} />
|
||||
|
||||
<PlayButton item={item} url={playbackUrl} className="grow mb-2" />
|
||||
</View>
|
||||
|
||||
<OverviewText text={item.Overview} />
|
||||
</View>
|
||||
<View className="flex flex-col p-4 w-full">
|
||||
<View className="flex flex-row items-center space-x-2 w-full">
|
||||
<BitrateSelector
|
||||
onChange={(val) => setMaxBitrate(val)}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
<AudioTrackSelector
|
||||
item={item}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
item={item}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex flex-row items-center justify-between w-full">
|
||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||
<PlayButton item={item} url={playbackUrl} className="grow" />
|
||||
<NextEpisodeButton item={item} className="ml-2" />
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-4">
|
||||
<CastAndCrew item={item} />
|
||||
{item.Type === "Episode" && <CurrentSeries item={item} />}
|
||||
<SimilarItems itemId={item.Id} />
|
||||
</View>
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
|
||||
<View className="h-12"></View>
|
||||
<OverviewText text={item?.Overview} className="px-4 mb-4" />
|
||||
|
||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||
|
||||
{item?.Type === "Episode" && (
|
||||
<CurrentSeries item={item} className="mb-4" />
|
||||
)}
|
||||
<SimilarItems itemId={item?.Id} />
|
||||
|
||||
<View className="h-16"></View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
});
|
||||
30
components/ItemHeader.tsx
Normal file
30
components/ItemHeader.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||
import { Ratings } from "./Ratings";
|
||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
if (!item)
|
||||
return (
|
||||
<View className="w-full items-center">
|
||||
<View className="w-1/3 h-4 mb-1 bg-neutral-950 rounded" />
|
||||
<View className="w-2/3 h-8 mb-1 bg-neutral-950 rounded" />
|
||||
<View className="w-2/3 h-3 mb-1 bg-neutral-950 rounded" />
|
||||
<View className="w-1/4 h-3 mb-1 bg-neutral-950 rounded" />
|
||||
<View className="w-1/4 h-6 bg-neutral-950 rounded" />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
{item.Type === "Episode" && <EpisodeTitleHeader item={item} />}
|
||||
{item.Type === "Movie" && <MoviesTitleHeader item={item} />}
|
||||
<Ratings item={item} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -10,35 +10,32 @@ interface Props extends ViewProps {
|
||||
|
||||
export const OverviewText: React.FC<Props> = ({
|
||||
text,
|
||||
characterLimit = 140,
|
||||
characterLimit = 100,
|
||||
...props
|
||||
}) => {
|
||||
const [limit, setLimit] = useState(characterLimit);
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
if (text.length > characterLimit)
|
||||
return (
|
||||
return (
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="text-xl font-bold mb-2">Overview</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
setLimit((prev) =>
|
||||
prev === characterLimit ? text.length : characterLimit
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<View {...props} className="">
|
||||
<View>
|
||||
<Text>{tc(text, limit)}</Text>
|
||||
<Text className="text-purple-600 mt-1">
|
||||
{limit === characterLimit ? "Show more" : "Show less"}
|
||||
</Text>
|
||||
{text.length > characterLimit && (
|
||||
<Text className="text-purple-600 mt-1">
|
||||
{limit === characterLimit ? "Show more" : "Show less"}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, type PropsWithChildren, type ReactElement } from "react";
|
||||
import { type PropsWithChildren, type ReactElement } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
@@ -6,7 +6,6 @@ import Animated, {
|
||||
useAnimatedStyle,
|
||||
useScrollViewOffset,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement;
|
||||
@@ -46,8 +45,6 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
||||
};
|
||||
});
|
||||
|
||||
const inset = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<Animated.ScrollView
|
||||
|
||||
@@ -2,10 +2,8 @@ import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BaseItemDto,
|
||||
PlaybackInfoResponse,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
@@ -13,7 +11,6 @@ import CastContext, {
|
||||
} from "react-native-google-cast";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item?: BaseItemDto | null;
|
||||
|
||||
@@ -5,10 +5,11 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
item?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const Ratings: React.FC<Props> = ({ item }) => {
|
||||
if (!item) return null;
|
||||
return (
|
||||
<View className="flex flex-row items-center justify-center mt-2 space-x-2">
|
||||
{item.OfficialRating && (
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ItemCardText } from "./ItemCardText";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
interface SimilarItemsProps extends ViewProps {
|
||||
itemId: string;
|
||||
itemId?: string | null;
|
||||
}
|
||||
|
||||
export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
||||
@@ -25,7 +25,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
||||
const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({
|
||||
queryKey: ["similarItems", itemId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
if (!api || !user?.Id || !itemId) return [];
|
||||
const response = await getLibraryApi(api).getSimilarItems({
|
||||
itemId,
|
||||
userId: user.Id,
|
||||
@@ -56,7 +56,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
||||
{movies.map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/items/${item.Id}`)}
|
||||
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
||||
className="flex flex-col w-32"
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
@@ -66,7 +66,9 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
{movies.length === 0 && <Text className="px-4">No similar items</Text>}
|
||||
{movies.length === 0 && (
|
||||
<Text className="px-4 text-neutral-500">No similar items</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,7 +47,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
<View className="flex flex-row items-center justify-between" {...props}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<View className="flex flex-col">
|
||||
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { FlashList, FlashListProps } from "@shopify/flash-list";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import { View, ViewStyle } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Loader } from "../Loader";
|
||||
import { Text } from "./Text";
|
||||
|
||||
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||
|
||||
export interface HorizontalScrollRef {
|
||||
scrollToIndex: (index: number, viewOffset: number) => void;
|
||||
}
|
||||
|
||||
interface HorizontalScrollProps<T>
|
||||
extends PartialExcept<
|
||||
Omit<FlashListProps<T>, "renderItem">,
|
||||
@@ -23,61 +21,69 @@ interface HorizontalScrollProps<T>
|
||||
loadingContainerStyle?: ViewStyle;
|
||||
height?: number;
|
||||
loading?: boolean;
|
||||
extraData?: any;
|
||||
}
|
||||
|
||||
export function HorizontalScroll<T>({
|
||||
data = [],
|
||||
renderItem,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
loadingContainerStyle,
|
||||
loading = false,
|
||||
height = 164,
|
||||
...props
|
||||
}: HorizontalScrollProps<T>): React.ReactElement {
|
||||
const animatedOpacity = useSharedValue(0);
|
||||
const animatedStyle1 = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: withTiming(animatedOpacity.value, { duration: 250 }),
|
||||
};
|
||||
});
|
||||
export const HorizontalScroll = forwardRef<
|
||||
HorizontalScrollRef,
|
||||
HorizontalScrollProps<any>
|
||||
>(
|
||||
<T,>(
|
||||
{
|
||||
data = [],
|
||||
renderItem,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
loadingContainerStyle,
|
||||
loading = false,
|
||||
height = 164,
|
||||
extraData,
|
||||
...props
|
||||
}: HorizontalScrollProps<T>,
|
||||
ref: React.ForwardedRef<HorizontalScrollRef>
|
||||
) => {
|
||||
const flashListRef = useRef<FlashList<T>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
animatedOpacity.value = 1;
|
||||
}
|
||||
}, [data]);
|
||||
useImperativeHandle(ref!, () => ({
|
||||
scrollToIndex: (index: number, viewOffset: number) => {
|
||||
flashListRef.current?.scrollToIndex({
|
||||
index,
|
||||
animated: true,
|
||||
viewPosition: 0,
|
||||
viewOffset,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
if (data === undefined || data === null || loading) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
loadingContainerStyle,
|
||||
]}
|
||||
>
|
||||
<Loader />
|
||||
const renderFlashListItem = ({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: T;
|
||||
index: number;
|
||||
}) => (
|
||||
<View className="mr-2">
|
||||
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
|
||||
<View className="mr-2">
|
||||
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||
</View>
|
||||
);
|
||||
if (!data || loading) {
|
||||
return (
|
||||
<View className="px-4 mb-2">
|
||||
<View className="bg-neutral-950 h-24 w-full rounded-md mb-2"></View>
|
||||
<View className="bg-neutral-950 h-10 w-full rounded-md mb-1"></View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[containerStyle, animatedStyle1]}>
|
||||
<FlashList
|
||||
return (
|
||||
<FlashList<T>
|
||||
ref={flashListRef}
|
||||
data={data}
|
||||
extraData={extraData}
|
||||
renderItem={renderFlashListItem}
|
||||
horizontal
|
||||
estimatedItemSize={100}
|
||||
estimatedItemSize={200}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
@@ -90,6 +96,6 @@ export function HorizontalScroll<T>({
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
101
components/common/ItemImage.tsx
Normal file
101
components/common/ItemImage.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image, ImageProps, ImageSource } from "expo-image";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
interface Props extends ImageProps {
|
||||
item: BaseItemDto;
|
||||
variant?: "Backdrop" | "Primary" | "Thumb" | "Logo";
|
||||
quality?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const ItemImage: React.FC<Props> = ({
|
||||
item,
|
||||
variant,
|
||||
quality = 90,
|
||||
width = 1000,
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const source = useMemo(() => {
|
||||
if (!api) return null;
|
||||
|
||||
let tag: string | null | undefined;
|
||||
let blurhash: string | null | undefined;
|
||||
let src: ImageSource | null = null;
|
||||
|
||||
switch (variant) {
|
||||
case "Backdrop":
|
||||
if (item.Type === "Episode") {
|
||||
tag = item.ParentBackdropImageTags?.[0];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Backdrop/0?quality=${quality}&tag=${tag}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
tag = item.BackdropImageTags?.[0];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop/0?quality=${quality}&tag=${tag}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
case "Primary":
|
||||
console.log("case Primary");
|
||||
tag = item.ImageTags?.["Primary"];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Primary?.[tag];
|
||||
console.log("bh: ", blurhash);
|
||||
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
case "Thumb":
|
||||
console.log("case Thumb");
|
||||
tag = item.ImageTags?.["Thumb"];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Thumb?.[tag];
|
||||
console.log("bh: ", blurhash);
|
||||
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
console.log("case default");
|
||||
tag = item.ImageTags?.["Primary"];
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return src;
|
||||
}, [item.ImageTags]);
|
||||
|
||||
return (
|
||||
<Image
|
||||
transition={300}
|
||||
placeholder={{
|
||||
blurhash: source?.blurhash,
|
||||
}}
|
||||
source={{
|
||||
uri: source?.uri,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -72,7 +72,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
// return;
|
||||
// }
|
||||
|
||||
router.push(`/(auth)/(tabs)/${from}/items/${item.Id}`);
|
||||
router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -46,7 +46,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||
{title}
|
||||
</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
height={orientation === "vertical" ? 247 : 164}
|
||||
loading={isLoading}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useRouter } from "expo-router";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { View, ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<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 className="flex flex-col" {...props}>
|
||||
<Text className="text-center font-bold text-2xl">{item?.Name}</Text>
|
||||
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,17 +13,19 @@ import { Text } from "../common/Text";
|
||||
import Poster from "../posters/Poster";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
item?: BaseItemDto | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const CastAndCrew: React.FC<Props> = ({ item, ...props }) => {
|
||||
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<View {...props} className="flex flex-col">
|
||||
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
||||
<HorizontalScroll<NonNullable<BaseItemPerson>>
|
||||
data={item.People}
|
||||
<HorizontalScroll
|
||||
loading={loading}
|
||||
data={item?.People || []}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Text } from "../common/Text";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
item?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
@@ -19,7 +19,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
<HorizontalScroll
|
||||
data={[item]}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
|
||||
40
components/series/EpisodeTitleHeader.tsx
Normal file
40
components/series/EpisodeTitleHeader.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter } from "expo-router";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View {...props} className="flex flex-col">
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(auth)/series/${item.SeriesId}`)}
|
||||
>
|
||||
<Text className="text-center opacity-50">{item?.SeriesName}</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-center font-bold text-2xl">{item?.Name}</Text>
|
||||
<View className="flex flex-row items-center self-center">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(
|
||||
// @ts-ignore
|
||||
`/(auth)/series/${item.SeriesId}?seasonIndex=${item?.ParentIndexNumber}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text className="text-center opacity-50">{item?.SeasonName}</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-center opacity-50 mx-2">{"—"}</Text>
|
||||
<Text className="text-center opacity-50">
|
||||
{`Episode ${item.IndexNumber}`}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -23,40 +23,6 @@ export const NextEpisodeButton: React.FC<Props> = ({
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
// const { data: seasons } = useQuery({
|
||||
// queryKey: ["seasons", item.SeriesId],
|
||||
// queryFn: async () => {
|
||||
// if (
|
||||
// !api ||
|
||||
// !user?.Id ||
|
||||
// !item?.Id ||
|
||||
// !item?.SeriesId ||
|
||||
// !item?.IndexNumber
|
||||
// )
|
||||
// return [];
|
||||
|
||||
// const response = await getItemsApi(api).getItems({
|
||||
// parentId: item?.SeriesId,
|
||||
// });
|
||||
|
||||
// console.log("seasons ~", type, response.data);
|
||||
|
||||
// return (response.data.Items as BaseItemDto[]) ?? [];
|
||||
// },
|
||||
// enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
|
||||
// });
|
||||
|
||||
// const nextSeason = useMemo(() => {
|
||||
// if (!seasons) return null;
|
||||
// const currentSeasonIndex = seasons.findIndex(
|
||||
// (season) => season.Id === item.SeasonId,
|
||||
// );
|
||||
|
||||
// if (currentSeasonIndex === seasons.length - 1) return null;
|
||||
|
||||
// return seasons[currentSeasonIndex + 1];
|
||||
// }, [seasons]);
|
||||
|
||||
const { data: nextEpisode } = useQuery({
|
||||
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
|
||||
queryFn: async () => {
|
||||
@@ -90,7 +56,7 @@ export const NextEpisodeButton: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
onPress={() => router.push(`/items/${nextEpisode?.Id}`)}
|
||||
onPress={() => router.setParams({ id: nextEpisode?.Id })}
|
||||
className={`h-12 aspect-square`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
|
||||
@@ -43,12 +43,12 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2 px-4">Next up</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
<HorizontalScroll
|
||||
data={items}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/items/${item.Id}`);
|
||||
router.push(`/(auth)/items/page?id=${item.Id}`);
|
||||
}}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-44"
|
||||
|
||||
146
components/series/SeasonEpisodesCarousel.tsx
Normal file
146
components/series/SeasonEpisodesCarousel.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
HorizontalScrollRef,
|
||||
} from "../common/HorrizontalScroll";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
item,
|
||||
loading,
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
|
||||
const scrollToIndex = (index: number) => {
|
||||
scrollRef.current?.scrollToIndex(index, 16);
|
||||
};
|
||||
|
||||
const seasonId = useMemo(() => {
|
||||
return item?.SeasonId;
|
||||
}, [item]);
|
||||
|
||||
const {
|
||||
data: episodes,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["episodes", seasonId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const response = await api.axiosInstance.get(
|
||||
`${api.basePath}/Shows/${item?.Id}/Episodes`,
|
||||
{
|
||||
params: {
|
||||
userId: user?.Id,
|
||||
seasonId,
|
||||
Fields:
|
||||
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
|
||||
},
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.Items as BaseItemDto[];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!seasonId,
|
||||
});
|
||||
|
||||
/**
|
||||
* Prefetch previous and next episode
|
||||
*/
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
if (!item?.Id || !item.IndexNumber || !episodes || episodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousId = episodes?.find(
|
||||
(ep) => ep.IndexNumber === item.IndexNumber! - 1
|
||||
)?.Id;
|
||||
if (previousId) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["item", previousId],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: previousId,
|
||||
}),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
const nextId = episodes?.find(
|
||||
(ep) => ep.IndexNumber === item.IndexNumber! + 1
|
||||
)?.Id;
|
||||
if (nextId) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["item", nextId],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: nextId,
|
||||
}),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
}, [episodes, api, user?.Id, item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (item?.Type === "Episode") {
|
||||
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
|
||||
if (index !== undefined && index !== -1) {
|
||||
console.log("Scrolling to index:", index);
|
||||
setTimeout(() => {
|
||||
scrollToIndex(index);
|
||||
}, 400);
|
||||
} else {
|
||||
console.log("Episode not found in the list:", item.Id);
|
||||
}
|
||||
}
|
||||
}, [episodes, item]);
|
||||
|
||||
return (
|
||||
<HorizontalScroll
|
||||
ref={scrollRef}
|
||||
data={episodes}
|
||||
extraData={item}
|
||||
loading={loading || isLoading || isFetching}
|
||||
renderItem={(_item, idx) => (
|
||||
<TouchableOpacity
|
||||
key={_item.Id}
|
||||
onPress={() => {
|
||||
router.setParams({ id: _item.Id });
|
||||
}}
|
||||
className={`flex flex-col w-44
|
||||
${item?.Id === _item.Id ? "" : "opacity-50"}
|
||||
`}
|
||||
>
|
||||
<ContinueWatchingPoster item={_item} useEpisodePoster />
|
||||
<ItemCardText item={_item} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -168,7 +168,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
{/* Old View. Might have a setting later to manually select view. */}
|
||||
{/* {episodes && (
|
||||
<View className="mt-4">
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
<HorizontalScroll
|
||||
data={episodes}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
@@ -200,7 +200,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
<TouchableOpacity
|
||||
key={e.Id}
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/items/${e.Id}`);
|
||||
router.push(`/(auth)/items/page?id=${e.Id}`);
|
||||
}}
|
||||
className="flex flex-col mb-4"
|
||||
>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useRouter } from "expo-router";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const SeriesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(auth)/series/${item.SeriesId}`)}
|
||||
>
|
||||
<Text className="text-center opacity-50">{item?.SeriesName}</Text>
|
||||
</TouchableOpacity>
|
||||
<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>
|
||||
<View className="flex flex-row items-center self-center">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(
|
||||
// @ts-ignore
|
||||
`/(auth)/series/${item.SeriesId}?seasonIndex=${item?.ParentIndexNumber}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text className="text-center opacity-50">{item?.SeasonName}</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-center opacity-50 mx-2">{"—"}</Text>
|
||||
<Text className="text-center opacity-50">
|
||||
{`Episode ${item.IndexNumber}`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,7 @@ const routes = [
|
||||
"artists/index",
|
||||
"artists/[artistId]",
|
||||
"collections/[collectionId]",
|
||||
"items/[id]",
|
||||
"items/page",
|
||||
"songs/[songId]",
|
||||
"series/[id]",
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user