fix: better posters and item screen

This commit is contained in:
Fredrik Burmester
2024-08-26 19:47:02 +02:00
parent 07c5c21599
commit 3047367ba6
29 changed files with 534 additions and 302 deletions

View File

@@ -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>

View 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);

View File

@@ -61,6 +61,7 @@ const page: React.FC = () => {
return (
<ParallaxScrollView
headerHeight={300}
headerImage={
<Image
source={{

View File

@@ -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

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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
View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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

View File

@@ -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;

View File

@@ -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 && (

View File

@@ -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>
);
};

View File

@@ -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">

View File

@@ -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>
);
}
);
}
);

View 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}
/>
);
};

View File

@@ -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}
>

View File

@@ -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}

View File

@@ -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>
);
};

View File

@@ -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={() => {

View File

@@ -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

View 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>
);
};

View File

@@ -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}

View File

@@ -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"

View 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}
/>
);
};

View File

@@ -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"
>

View File

@@ -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>
</>
);
};

View File

@@ -17,7 +17,7 @@ const routes = [
"artists/index",
"artists/[artistId]",
"collections/[collectionId]",
"items/[id]",
"items/page",
"songs/[songId]",
"series/[id]",
];