chore: Apply linting rules and add git hok (#611)

Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
This commit is contained in:
lostb1t
2025-03-16 18:01:12 +01:00
committed by GitHub
parent 2688e1b981
commit 92513e234f
268 changed files with 9197 additions and 8394 deletions

View File

@@ -1,21 +1,20 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useFavorite } from "@/hooks/useFavorite";
import {View, ViewProps} from "react-native";
import { RoundButton } from "@/components/RoundButton";
import {FC} from "react";
import { useFavorite } from "@/hooks/useFavorite";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react";
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const AddToFavorites:FC<Props> = ({ item, ...props }) => {
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
const { isFavorite, toggleFavorite } = useFavorite(item);
return (
<View {...props}>
<RoundButton
size="large"
size='large'
icon={isFavorite ? "heart" : "heart-outline"}
fillColor={isFavorite ? "primary" : undefined}
onPress={toggleFavorite}

View File

@@ -1,9 +1,9 @@
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
@@ -20,31 +20,31 @@ export const AudioTrackSelector: React.FC<Props> = ({
if (Platform.isTV) return null;
const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
[source]
[source],
);
const selectedAudioSteam = useMemo(
() => audioStreams?.find((x) => x.Index === selected),
[audioStreams, selected]
[audioStreams, selected],
);
const { t } = useTranslation();
return (
<View
className="flex shrink"
className='flex shrink'
style={{
minWidth: 50,
}}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.audio")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="" numberOfLines={1}>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text className='' numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle}
</Text>
</TouchableOpacity>
@@ -52,8 +52,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}

View File

@@ -1,4 +1,4 @@
import { View, ViewProps } from "react-native";
import { View, type ViewProps } from "react-native";
import { Text } from "./common/Text";
interface Props extends ViewProps {
@@ -22,7 +22,7 @@ export const Badge: React.FC<Props> = ({
${variant === "gray" && "bg-neutral-800"}
`}
>
{iconLeft && <View className="mr-1">{iconLeft}</View>}
{iconLeft && <View className='mr-1'>{iconLeft}</View>}
<Text
className={`
text-xs

View File

@@ -1,8 +1,8 @@
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
export type Bitrate = {
key: string;
@@ -40,7 +40,11 @@ export const BITRATES: Bitrate[] = [
key: "250 Kb/s",
value: 250000,
},
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
].sort(
(a, b) =>
(b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY),
);
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void;
@@ -58,10 +62,14 @@ export const BitrateSelector: React.FC<Props> = ({
const sorted = useMemo(() => {
if (inverted)
return BITRATES.sort(
(a, b) => (a.value || Infinity) - (b.value || Infinity)
(a, b) =>
(a.value || Number.POSITIVE_INFINITY) -
(b.value || Number.POSITIVE_INFINITY),
);
return BITRATES.sort(
(a, b) => (b.value || Infinity) - (a.value || Infinity)
(a, b) =>
(b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY),
);
}, []);
@@ -69,7 +77,7 @@ export const BitrateSelector: React.FC<Props> = ({
return (
<View
className="flex shrink"
className='flex shrink'
style={{
minWidth: 60,
maxWidth: 200,
@@ -77,12 +85,12 @@ export const BitrateSelector: React.FC<Props> = ({
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.quality")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
</Text>
</TouchableOpacity>
@@ -90,8 +98,8 @@ export const BitrateSelector: React.FC<Props> = ({
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}

View File

@@ -1,5 +1,6 @@
import { useHaptic } from "@/hooks/useHaptic";
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import type React from "react";
import { type PropsWithChildren, type ReactNode, useMemo } from "react";
import { Platform, Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader";
@@ -63,7 +64,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
{...props}
>
{loading ? (
<View className="p-0.5">
<View className='p-0.5'>
<Loader />
</View>
) : (
@@ -72,7 +73,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
flex flex-row items-center justify-between w-full
${justify === "between" ? "justify-between" : "justify-center"}`}
>
{iconLeft ? iconLeft : <View className="w-4"></View>}
{iconLeft ? iconLeft : <View className='w-4'></View>}
<Text
className={`
text-white font-bold text-base
@@ -84,7 +85,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
>
{children}
</Text>
{iconRight ? iconRight : <View className="w-4"></View>}
{iconRight ? iconRight : <View className='w-4'></View>}
</View>
)}
</TouchableOpacity>

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons";
import React, { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity, ViewProps } from "react-native";
import { Platform, TouchableOpacity, type ViewProps } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
@@ -45,18 +45,18 @@ export function Chromecast({
const AndroidCastButton = useCallback(
() =>
Platform.OS === "android" ? (
<CastButton tintColor="transparent" />
<CastButton tintColor='transparent' />
) : (
<></>
),
[Platform.OS]
[Platform.OS],
);
if (background === "transparent")
return (
<RoundButton
size="large"
className="mr-2"
size='large'
className='mr-2'
background={false}
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
@@ -65,13 +65,13 @@ export function Chromecast({
{...props}
>
<AndroidCastButton />
<Feather name="cast" size={22} color={"white"} />
<Feather name='cast' size={22} color={"white"} />
</RoundButton>
);
return (
<RoundButton
size="large"
size='large'
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
@@ -79,7 +79,7 @@ export function Chromecast({
{...props}
>
<AndroidCastButton />
<Feather name="cast" size={22} color={"white"} />
<Feather name='cast' size={22} color={"white"} />
</RoundButton>
);
}

View File

@@ -1,12 +1,12 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import type React from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
import React from "react";
import { Ionicons } from "@expo/vector-icons";
type ContinueWatchingPosterProps = {
item: BaseItemDto;
@@ -71,7 +71,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
if (!url)
return (
<View className="aspect-video border border-neutral-800 w-44"></View>
<View className='aspect-video border border-neutral-800 w-44'></View>
);
return (
@@ -81,7 +81,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
${size === "small" ? "w-32" : "w-44"}
`}
>
<View className="w-full h-full flex items-center justify-center">
<View className='w-full h-full flex items-center justify-center'>
<Image
key={item.Id}
id={item.Id}
@@ -89,12 +89,12 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
contentFit='cover'
className='w-full h-full'
/>
{showPlayButton && (
<View className="absolute inset-0 flex items-center justify-center">
<Ionicons name="play-circle" size={40} color="white" />
<View className='absolute inset-0 flex items-center justify-center'>
<Ionicons name='play-circle' size={40} color='white' />
</View>
)}
</View>

View File

@@ -10,29 +10,30 @@ import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import {
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Href, router, useFocusEffect } from "expo-router";
import { type Href, router, useFocusEffect } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Platform, View, ViewProps } from "react-native";
import type React from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { t } from "i18next";
import { Text } from "./common/Text";
interface DownloadProps extends ViewProps {
items: BaseItemDto[];
@@ -70,16 +71,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
settings?.defaultBitrate ?? {
key: "Max",
value: undefined,
}
},
);
const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading,
[user]
[user],
);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings]
[settings],
);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -99,7 +100,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const itemsNotDownloaded = useMemo(
() =>
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
[items, downloadedFiles]
[items, downloadedFiles],
);
const allItemsDownloaded = useMemo(() => {
@@ -108,7 +109,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}, [items, itemsNotDownloaded]);
const itemsProcesses = useMemo(
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
[processes, itemIds]
[processes, itemIds],
);
const progress = useMemo(() => {
@@ -140,7 +141,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
params: {
episodeSeasonIndex: firstItem.ParentIndexNumber,
},
} as Href)
} as Href),
);
};
@@ -160,12 +161,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
}))
})),
);
}
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files")
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [
@@ -189,7 +190,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
"DownloadItem ~ initiateDownload: No api or user or item",
);
}
let mediaSource = selectedMediaSource;
@@ -220,7 +221,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!res) {
Alert.alert(
t("home.downloads.something_went_wrong"),
t("home.downloads.could_not_get_stream_url_from_jellyfin")
t("home.downloads.could_not_get_stream_url_from_jellyfin"),
);
continue;
}
@@ -250,7 +251,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
usingOptimizedServer,
startBackgroundDownload,
startRemuxing,
]
],
);
const renderBackdrop = useCallback(
@@ -261,7 +262,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
appearsOnIndex={0}
/>
),
[]
[],
);
useFocusEffect(
useCallback(() => {
@@ -274,7 +275,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, itemsNotDownloaded, settings])
}, [items, itemsNotDownloaded, settings]),
);
const renderButtonContent = () => {
@@ -282,18 +283,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
return progress === 0 ? (
<Loader />
) : (
<View className="-rotate-45">
<View className='-rotate-45'>
<ProgressCircle
size={24}
fill={progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
tintColor='#9334E9'
backgroundColor='#bdc3c7'
/>
</View>
);
} else if (itemsQueued) {
return <Ionicons name="hourglass" size={24} color="white" />;
return <Ionicons name='hourglass' size={24} color='white' />;
} else if (allItemsDownloaded) {
return <DownloadedIconComponent />;
} else {
@@ -331,19 +332,19 @@ export const DownloadItems: React.FC<DownloadProps> = ({
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className="font-bold text-2xl text-neutral-100">
<Text className='font-bold text-2xl text-neutral-100'>
{title}
</Text>
<Text className="text-neutral-300">
<Text className='text-neutral-300'>
{subtitle ||
t("item_card.download.download_x_item", {
item_count: itemsNotDownloaded.length,
})}
</Text>
</View>
<View className="flex flex-col space-y-2 w-full items-start">
<View className='flex flex-col space-y-2 w-full items-start'>
<BitrateSelector
inverted
onChange={setMaxBitrate}
@@ -357,7 +358,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
selected={selectedMediaSource}
/>
{selectedMediaSource && (
<View className="flex flex-col space-y-2">
<View className='flex flex-col space-y-2'>
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
@@ -374,14 +375,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)}
</View>
<Button
className="mt-auto"
className='mt-auto'
onPress={acceptDownloadOptions}
color="purple"
color='purple'
>
{t("item_card.download.download_button")}
</Button>
<View className="opacity-70 text-center w-full flex items-center">
<Text className="text-xs">
<View className='opacity-70 text-center w-full flex items-center'>
<Text className='text-xs'>
{usingOptimizedServer
? t("item_card.download.using_optimized_server")
: t("item_card.download.using_default_method")}
@@ -411,10 +412,10 @@ export const DownloadSingleItem: React.FC<{
subtitle={item.Name!}
items={[item]}
MissingDownloadIconComponent={() => (
<Ionicons name="cloud-download-outline" size={24} color="white" />
<Ionicons name='cloud-download-outline' size={24} color='white' />
)}
DownloadedIconComponent={() => (
<Ionicons name="cloud-download" size={26} color="#9333ea" />
<Ionicons name='cloud-download' size={26} color='#9333ea' />
)}
/>
);

View File

@@ -1,49 +1,57 @@
// GenreTags.tsx
import React from "react";
import {StyleProp, TextStyle, View, ViewProps} from "react-native";
import type React from "react";
import {
type StyleProp,
type TextStyle,
View,
type ViewProps,
} from "react-native";
import { Text } from "./common/Text";
interface TagProps {
tags?: string[];
textClass?: ViewProps["className"]
textClass?: ViewProps["className"];
}
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp<TextStyle>} & ViewProps> = ({
text,
textClass,
textStyle,
...props
}) => {
export const Tag: React.FC<
{
text: string;
textClass?: ViewProps["className"];
textStyle?: StyleProp<TextStyle>;
} & ViewProps
> = ({ text, textClass, textStyle, ...props }) => {
return (
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
<Text className={textClass} style={textStyle}>{text}</Text>
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
<Text className={textClass} style={textStyle}>
{text}
</Text>
</View>
);
};
export const Tags: React.FC<TagProps & {tagProps?: ViewProps} & ViewProps> = ({
tags,
textClass = "text-xs",
tagProps,
...props
}) => {
export const Tags: React.FC<
TagProps & { tagProps?: ViewProps } & ViewProps
> = ({ tags, textClass = "text-xs", tagProps, ...props }) => {
if (!tags || tags.length === 0) return null;
return (
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
<View
className={`flex flex-row flex-wrap gap-1 ${props.className}`}
{...props}
>
{tags.map((tag, idx) => (
<View key={idx}>
<Tag key={idx} textClass={textClass} text={tag} {...tagProps}/>
<Tag key={idx} textClass={textClass} text={tag} {...tagProps} />
</View>
))}
</View>
);
};
export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => {
export const GenreTags: React.FC<{ genres?: string[] }> = ({ genres }) => {
return (
<View className="mt-2">
<Tags tags={genres}/>
<View className='mt-2'>
<Tags tags={genres} />
</View>
);
};

View File

@@ -1,8 +1,8 @@
import React from "react";
import { tc } from "@/utils/textTools";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { View } from "react-native";
import { Text } from "./common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
type ItemCardProps = {
item: BaseItemDto;
@@ -10,13 +10,13 @@ type ItemCardProps = {
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
return (
<View className="mt-2 flex flex-col">
<View className='mt-2 flex flex-col'>
{item.Type === "Episode" ? (
<>
<Text numberOfLines={1} ellipsizeMode="tail" className="">
<Text numberOfLines={1} ellipsizeMode='tail' className=''>
{item.Name}
</Text>
<Text numberOfLines={1} className="text-xs opacity-50">
<Text numberOfLines={1} className='text-xs opacity-50'>
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
{item.SeriesName}
@@ -24,8 +24,10 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
</>
) : (
<>
<Text numberOfLines={1} ellipsizeMode="tail">{item.Name}</Text>
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
<Text numberOfLines={1} ellipsizeMode='tail'>
{item.Name}
</Text>
<Text className='text-xs opacity-50'>{item.ProductionYear}</Text>
</>
)}
</View>

View File

@@ -1,5 +1,5 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
@@ -19,7 +19,7 @@ import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import {
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
@@ -86,18 +86,18 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
navigation.setOptions({
headerRight: () =>
item && (
<View className="flex flex-row items-center space-x-2">
<View className='flex flex-row items-center space-x-2'>
<Chromecast.Chromecast
background="blur"
background='blur'
width={22}
height={22}
/>
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<DownloadSingleItem item={item} size="large" />
<DownloadSingleItem item={item} size='large' />
)}
<PlayedStatus items={[item]} size="large" />
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
</View>
)}
@@ -123,7 +123,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
return (
<View
className="flex-1 relative"
className='flex-1 relative'
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
@@ -165,22 +165,22 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</>
}
>
<View className="flex flex-col bg-transparent shrink">
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<ItemHeader item={item} className="mb-4" />
<View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-4' />
{item.Type !== "Program" && !Platform.isTV && (
<View className="flex flex-row items-center justify-start w-full h-16">
<View className='flex flex-row items-center justify-start w-full h-16'>
<BitrateSelector
className="mr-1"
className='mr-1'
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val }
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions.bitrate}
/>
<MediaSourceSelector
className="mr-1"
className='mr-1'
item={item}
onChange={(val) =>
setSelectedOptions(
@@ -188,13 +188,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && {
...prev,
mediaSource: val,
}
},
)
}
selected={selectedOptions.mediaSource}
/>
<AudioTrackSelector
className="mr-1"
className='mr-1'
source={selectedOptions.mediaSource}
onChange={(val) => {
setSelectedOptions(
@@ -202,7 +202,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && {
...prev,
audioIndex: val,
}
},
);
}}
selected={selectedOptions.audioIndex}
@@ -215,7 +215,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && {
...prev,
subtitleIndex: val,
}
},
)
}
selected={selectedOptions.subtitleIndex}
@@ -224,7 +224,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
)}
<PlayButton
className="grow"
className='grow'
selectedOptions={selectedOptions}
item={item}
/>
@@ -235,24 +235,24 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
)}
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
<OverviewText text={item.Overview} className="px-4 mb-4" />
<OverviewText text={item.Overview} className='px-4 mb-4' />
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
<CurrentSeries item={item} className='mb-4' />
)}
<CastAndCrew item={item} className="mb-4" loading={loading} />
<CastAndCrew item={item} className='mb-4' loading={loading} />
{item.People && item.People.length > 0 && (
<View className="mb-4">
<View className='mb-4'>
{item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor
currentItem={item}
key={idx}
actorId={person.Id!}
className="mb-4"
className='mb-4'
/>
))}
</View>
@@ -265,5 +265,5 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</ParallaxScrollView>
</View>
);
}
},
);

View File

@@ -1,9 +1,9 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { View, ViewProps } from "react-native";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { View, type ViewProps } from "react-native";
import { GenreTags } from "./GenreTags";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { Ratings } from "./Ratings";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
import { ItemActions } from "./series/SeriesActions";
@@ -15,21 +15,21 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
if (!item)
return (
<View
className="flex flex-col space-y-1.5 w-full items-start h-32"
className='flex flex-col space-y-1.5 w-full items-start h-32'
{...props}
>
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
<View className="w-2/3 h-8 bg-neutral-900 rounded" />
<View className="w-2/3 h-4 bg-neutral-900 rounded" />
<View className="w-1/4 h-4 bg-neutral-900 rounded" />
<View className='w-1/3 h-6 bg-neutral-900 rounded' />
<View className='w-2/3 h-8 bg-neutral-900 rounded' />
<View className='w-2/3 h-4 bg-neutral-900 rounded' />
<View className='w-1/4 h-4 bg-neutral-900 rounded' />
</View>
);
return (
<View className="flex flex-col" {...props}>
<View className="flex flex-col" {...props}>
<View className="flex flex-row items-center justify-between">
<Ratings item={item} className="mb-2" />
<View className='flex flex-col' {...props}>
<View className='flex flex-col' {...props}>
<View className='flex flex-row items-center justify-between'>
<Ratings item={item} className='mb-2' />
<ItemActions item={item} />
</View>
{item.Type === "Episode" && (

View File

@@ -1,22 +1,23 @@
import { formatBitrate } from "@/utils/bitrate";
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type {
MediaSourceInfo,
type MediaStream,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
import React, { useMemo, useRef } from "react";
import type React from "react";
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import { Badge } from "./Badge";
import { Text } from "./common/Text";
import {
BottomSheetModal,
BottomSheetBackdropProps,
BottomSheetBackdrop,
BottomSheetView,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { Button } from "./Button";
import { useTranslation } from "react-i18next";
import { formatBitrate } from "@/utils/bitrate";
import { Text } from "./common/Text";
interface Props {
source?: MediaSourceInfo;
@@ -27,13 +28,13 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
const { t } = useTranslation();
return (
<View className="px-4 mt-2 mb-4">
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
<View className='px-4 mt-2 mb-4'>
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
<View className="flex flex-row space-x-2">
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} />
</View>
<Text className="text-purple-600">{t("item_card.more_details")}</Text>
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>
</TouchableOpacity>
<BottomSheetModal
ref={bottomSheetModalRef}
@@ -53,37 +54,37 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
)}
>
<BottomSheetScrollView>
<View className="flex flex-col space-y-2 p-4 mb-4">
<View className="">
<Text className="text-lg font-bold mb-4">
<View className='flex flex-col space-y-2 p-4 mb-4'>
<View className=''>
<Text className='text-lg font-bold mb-4'>
{t("item_card.video")}
</Text>
<View className="flex flex-row space-x-2">
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} />
</View>
</View>
<View className="">
<Text className="text-lg font-bold mb-2">
<View className=''>
<Text className='text-lg font-bold mb-2'>
{t("item_card.audio")}
</Text>
<AudioStreamInfo
audioStreams={
source?.MediaStreams?.filter(
(stream) => stream.Type === "Audio"
(stream) => stream.Type === "Audio",
) || []
}
/>
</View>
<View className="">
<Text className="text-lg font-bold mb-2">
<View className=''>
<Text className='text-lg font-bold mb-2'>
{t("item_card.subtitles")}
</Text>
<SubtitleStreamInfo
subtitleStreams={
source?.MediaStreams?.filter(
(stream) => stream.Type === "Subtitle"
(stream) => stream.Type === "Subtitle",
) || []
}
/>
@@ -101,25 +102,25 @@ const SubtitleStreamInfo = ({
subtitleStreams: MediaStream[];
}) => {
return (
<View className="flex flex-col">
<View className='flex flex-col'>
{subtitleStreams.map((stream, index) => (
<View key={stream.Index} className="flex flex-col">
<Text className="text-xs mb-3 text-neutral-400">
<View key={stream.Index} className='flex flex-col'>
<Text className='text-xs mb-3 text-neutral-400'>
{stream.DisplayTitle}
</Text>
<View className="flex flex-row flex-wrap gap-2">
<View className='flex flex-row flex-wrap gap-2'>
<Badge
variant="gray"
variant='gray'
iconLeft={
<Ionicons name="language-outline" size={16} color="white" />
<Ionicons name='language-outline' size={16} color='white' />
}
text={stream.Language}
/>
<Badge
variant="gray"
variant='gray'
text={stream.Codec}
iconLeft={
<Ionicons name="layers-outline" size={16} color="white" />
<Ionicons name='layers-outline' size={16} color='white' />
}
/>
</View>
@@ -131,40 +132,40 @@ const SubtitleStreamInfo = ({
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
return (
<View className="flex flex-col">
<View className='flex flex-col'>
{audioStreams.map((audioStreams, index) => (
<View key={index} className="flex flex-col">
<Text className="mb-3 text-neutral-400 text-xs">
<View key={index} className='flex flex-col'>
<Text className='mb-3 text-neutral-400 text-xs'>
{audioStreams.DisplayTitle}
</Text>
<View className="flex-row flex-wrap gap-2">
<View className='flex-row flex-wrap gap-2'>
<Badge
variant="gray"
variant='gray'
iconLeft={
<Ionicons name="language-outline" size={16} color="white" />
<Ionicons name='language-outline' size={16} color='white' />
}
text={audioStreams.Language}
/>
<Badge
variant="gray"
variant='gray'
iconLeft={
<Ionicons
name="musical-notes-outline"
name='musical-notes-outline'
size={16}
color="white"
color='white'
/>
}
text={audioStreams.Codec}
/>
<Badge
variant="gray"
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
variant='gray'
iconLeft={<Ionicons name='mic-outline' size={16} color='white' />}
text={audioStreams.ChannelLayout}
/>
<Badge
variant="gray"
variant='gray'
iconLeft={
<Ionicons name="speedometer-outline" size={16} color="white" />
<Ionicons name='speedometer-outline' size={16} color='white' />
}
text={formatBitrate(audioStreams.BitRate)}
/>
@@ -180,48 +181,48 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
const videoStream = useMemo(() => {
return source.MediaStreams?.find(
(stream) => stream.Type === "Video"
(stream) => stream.Type === "Video",
) as MediaStream;
}, [source.MediaStreams]);
if (!videoStream) return null;
return (
<View className="flex-row flex-wrap gap-2">
<View className='flex-row flex-wrap gap-2'>
<Badge
variant="gray"
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
variant='gray'
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
text={formatFileSize(source.Size)}
/>
<Badge
variant="gray"
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
variant='gray'
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
text={`${videoStream.Width}x${videoStream.Height}`}
/>
<Badge
variant="gray"
variant='gray'
iconLeft={
<Ionicons name="color-palette-outline" size={16} color="white" />
<Ionicons name='color-palette-outline' size={16} color='white' />
}
text={videoStream.VideoRange}
/>
<Badge
variant="gray"
variant='gray'
iconLeft={
<Ionicons name="code-working-outline" size={16} color="white" />
<Ionicons name='code-working-outline' size={16} color='white' />
}
text={videoStream.Codec}
/>
<Badge
variant="gray"
variant='gray'
iconLeft={
<Ionicons name="speedometer-outline" size={16} color="white" />
<Ionicons name='speedometer-outline' size={16} color='white' />
}
text={formatBitrate(videoStream.BitRate)}
/>
<Badge
variant="gray"
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
variant='gray'
iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
/>
</View>
@@ -233,6 +234,8 @@ const formatFileSize = (bytes?: number | null) => {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Byte";
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
const i = Number.parseInt(
Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
);
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
};

View File

@@ -1,10 +1,10 @@
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
import type React from "react";
import { useTranslation } from "react-i18next";
import { Text, TouchableOpacity, View } from "react-native";
import { Button } from "./Button";
import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem";
import { useTranslation } from "react-i18next";
interface Props {
onServerSelect?: (server: { address: string; serverName?: string }) => void;
@@ -15,15 +15,17 @@ const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => {
const { t } = useTranslation();
return (
<View className="mt-2">
<Button onPress={startDiscovery} color="black">
<Text className="text-white text-center">
{isSearching ? t("server.searching") : t("server.search_for_local_servers")}
<View className='mt-2'>
<Button onPress={startDiscovery} color='black'>
<Text className='text-white text-center'>
{isSearching
? t("server.searching")
: t("server.search_for_local_servers")}
</Text>
</Button>
{servers.length ? (
<ListGroup title={t("server.servers")} className="mt-4">
<ListGroup title={t("server.servers")} className='mt-4'>
{servers.map((server) => (
<ListItem
key={server.address}

View File

@@ -1,6 +1,6 @@
import {
ActivityIndicator,
ActivityIndicatorProps,
type ActivityIndicatorProps,
Platform,
View,
} from "react-native";

View File

@@ -1,12 +1,12 @@
import {
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
@@ -24,9 +24,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
const selectedName = useMemo(
() =>
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video"
(x) => x.Type === "Video",
)?.DisplayTitle || "",
[item, selected]
[item, selected],
);
const { t } = useTranslation();
@@ -54,26 +54,26 @@ export const MediaSourceSelector: React.FC<Props> = ({
return (
<View
className="flex shrink"
className='flex shrink'
style={{
minWidth: 50,
}}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.video")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'>
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}

View File

@@ -1,17 +1,17 @@
import React from "react";
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { ItemCardText } from "@/components/ItemCardText";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import MoviePoster from "@/components/posters/MoviePoster";
import { ItemCardText } from "@/components/ItemCardText";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import type React from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {
actorId: string;
@@ -77,8 +77,8 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
return (
<View {...props}>
<Text className="text-lg font-bold mb-2 px-4">
{t("item_card.more_with", {name: actor?.Name})}
<Text className='text-lg font-bold mb-2 px-4'>
{t("item_card.more_with", { name: actor?.Name })}
</Text>
<HorizontalScroll
data={items}
@@ -88,7 +88,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
<TouchableItemRouter
key={idx}
item={item}
className="flex flex-col w-28"
className='flex flex-col w-28'
>
<View>
<MoviePoster item={item} />

View File

@@ -1,8 +1,8 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { tc } from "@/utils/textTools";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
interface Props extends ViewProps {
text?: string | null;
@@ -20,20 +20,22 @@ export const OverviewText: React.FC<Props> = ({
if (!text) return null;
return (
<View className="flex flex-col" {...props}>
<Text className="text-lg font-bold mb-2">{t("item_card.overview")}</Text>
<View className='flex flex-col' {...props}>
<Text className='text-lg font-bold mb-2'>{t("item_card.overview")}</Text>
<TouchableOpacity
onPress={() =>
setLimit((prev) =>
prev === characterLimit ? text.length : characterLimit
prev === characterLimit ? text.length : characterLimit,
)
}
>
<View>
<Text>{tc(text, limit)}</Text>
{text.length > characterLimit && (
<Text className="text-purple-600 mt-1">
{limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")}
<Text className='text-purple-600 mt-1'>
{limit === characterLimit
? t("item_card.show_more")
: t("item_card.show_less")}
</Text>
)}
</View>

View File

@@ -1,6 +1,11 @@
import { LinearGradient } from "expo-linear-gradient";
import { type PropsWithChildren, type ReactElement } from "react";
import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native";
import type { PropsWithChildren, ReactElement } from "react";
import {
type NativeScrollEvent,
NativeSyntheticEvent,
View,
type ViewProps,
} from "react-native";
import Animated, {
interpolate,
useAnimatedRef,
@@ -35,36 +40,40 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
translateY: interpolate(
scrollOffset.value,
[-headerHeight, 0, headerHeight],
[-headerHeight / 2, 0, headerHeight * 0.75]
[-headerHeight / 2, 0, headerHeight * 0.75],
),
},
{
scale: interpolate(
scrollOffset.value,
[-headerHeight, 0, headerHeight],
[2, 1, 1]
[2, 1, 1],
),
},
],
};
});
function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) {
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
function isCloseToBottom({
layoutMeasurement,
contentOffset,
contentSize,
}: NativeScrollEvent) {
return (
layoutMeasurement.height + contentOffset.y >= contentSize.height - 20
);
}
return (
<View className="flex-1" {...props}>
<View className='flex-1' {...props}>
<Animated.ScrollView
style={{
position: "relative",
}}
ref={scrollRef}
scrollEventThrottle={16}
onScroll={e => {
if (isCloseToBottom(e.nativeEvent))
onEndReached?.()
onScroll={(e) => {
if (isCloseToBottom(e.nativeEvent)) onEndReached?.();
}}
>
{logo && (
@@ -73,7 +82,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
top: headerHeight - 200,
height: 130,
}}
className="absolute left-0 w-full z-40 px-4 flex justify-center items-center"
className='absolute left-0 w-full z-40 px-4 flex justify-center items-center'
>
{logo}
</View>
@@ -95,7 +104,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
style={{
top: -50,
}}
className="relative flex-1 bg-transparent pb-24"
className='relative flex-1 bg-transparent pb-24'
>
<LinearGradient
// Background Linear Gradient

View File

@@ -1,6 +1,6 @@
import { BlurView } from "expo-blur";
import React from "react";
import { Platform, View, ViewProps } from "react-native";
import type React from "react";
import { Platform, View, type ViewProps } from "react-native";
interface Props extends ViewProps {
blurAmount?: number;
blurType?: "light" | "dark" | "xlight";

View File

@@ -1,18 +1,22 @@
import { Platform, Pressable } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import ios from "@/utils/profiles/ios";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform, Pressable } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
@@ -30,12 +34,8 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
@@ -74,7 +74,7 @@ export const PlayButton: React.FC<Props> = ({
(q: string) => {
router.push(`/player/direct-player?${q}`);
},
[router]
[router],
);
const onPress = useCallback(async () => {
@@ -140,7 +140,7 @@ export const PlayButton: React.FC<Props> = ({
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast")
t("player.could_not_create_stream_for_chromecast"),
);
return;
}
@@ -170,36 +170,36 @@ export const PlayButton: React.FC<Props> = ({
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: 0,
})
@@ -222,7 +222,7 @@ export const PlayButton: React.FC<Props> = ({
case cancelButtonIndex:
break;
}
}
},
);
}, [
item,
@@ -243,7 +243,7 @@ export const PlayButton: React.FC<Props> = ({
return userData.PlaybackPositionTicks > 0
? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH
MIN_PLAYBACK_WIDTH,
)
: 0;
}
@@ -260,7 +260,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
});
},
[item]
[item],
);
useAnimatedReaction(
@@ -273,7 +273,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[colorAtom]
[colorAtom],
);
useEffect(() => {
@@ -294,7 +294,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary]
[startColor.value.primary, endColor.value.primary],
),
}));
@@ -302,7 +302,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary]
[startColor.value.primary, endColor.value.primary],
),
}));
@@ -310,7 +310,7 @@ export const PlayButton: React.FC<Props> = ({
width: `${interpolate(
widthProgress.value,
[0, 1],
[startWidth.value, targetWidth.value]
[startWidth.value, targetWidth.value],
)}%`,
}));
@@ -318,7 +318,7 @@ export const PlayButton: React.FC<Props> = ({
color: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.text, endColor.value.text]
[startColor.value.text, endColor.value.text],
),
}));
/**
@@ -328,13 +328,13 @@ export const PlayButton: React.FC<Props> = ({
return (
<TouchableOpacity
disabled={!item}
accessibilityLabel="Play button"
accessibilityHint="Tap to play the media"
accessibilityLabel='Play button'
accessibilityHint='Tap to play the media'
onPress={onPress}
className={`relative`}
{...props}
>
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
<Animated.View
style={[
animatedPrimaryStyle,
@@ -348,7 +348,7 @@ export const PlayButton: React.FC<Props> = ({
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl"
className='absolute w-full h-full top-0 left-0 rounded-xl'
/>
<View
style={{
@@ -356,25 +356,25 @@ export const PlayButton: React.FC<Props> = ({
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
>
<View className="flex flex-row items-center space-x-2">
<View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} />
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} />
<CastButton tintColor="transparent" />
<Feather name='cast' size={22} />
<CastButton tintColor='transparent' />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name="vlc"
name='vlc'
size={18}
color={animatedTextStyle.color}
/>

View File

@@ -1,14 +1,16 @@
import { Platform } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native";
import Animated, {
Easing,
@@ -20,10 +22,8 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
@@ -34,10 +34,10 @@ const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
...props
}: Props) => {
item,
selectedOptions,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
const { t } = useTranslation();
@@ -60,7 +60,7 @@ export const PlayButton: React.FC<Props> = ({
(q: string) => {
router.push(`/player/direct-player?${q}`);
},
[router]
[router],
);
const onPress = () => {
@@ -88,9 +88,9 @@ export const PlayButton: React.FC<Props> = ({
if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH
)
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH,
)
: 0;
}
return 0;
@@ -106,7 +106,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
});
},
[item]
[item],
);
useAnimatedReaction(
@@ -119,7 +119,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[colorAtom]
[colorAtom],
);
useEffect(() => {
@@ -140,7 +140,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary]
[startColor.value.primary, endColor.value.primary],
),
}));
@@ -148,7 +148,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary]
[startColor.value.primary, endColor.value.primary],
),
}));
@@ -156,7 +156,7 @@ export const PlayButton: React.FC<Props> = ({
width: `${interpolate(
widthProgress.value,
[0, 1],
[startWidth.value, targetWidth.value]
[startWidth.value, targetWidth.value],
)}%`,
}));
@@ -164,7 +164,7 @@ export const PlayButton: React.FC<Props> = ({
color: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.text, endColor.value.text]
[startColor.value.text, endColor.value.text],
),
}));
/**
@@ -173,13 +173,13 @@ export const PlayButton: React.FC<Props> = ({
return (
<TouchableOpacity
accessibilityLabel="Play button"
accessibilityHint="Tap to play the media"
accessibilityLabel='Play button'
accessibilityHint='Tap to play the media'
onPress={onPress}
className={`relative`}
{...props}
>
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
<Animated.View
style={[
animatedPrimaryStyle,
@@ -193,7 +193,7 @@ export const PlayButton: React.FC<Props> = ({
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl"
className='absolute w-full h-full top-0 left-0 rounded-xl'
/>
<View
style={{
@@ -201,19 +201,19 @@ export const PlayButton: React.FC<Props> = ({
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
>
<View className="flex flex-row items-center space-x-2">
<View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} />
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name="vlc"
name='vlc'
size={18}
color={animatedTextStyle.color}
/>

View File

@@ -1,8 +1,8 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import React from "react";
import { View, ViewProps } from "react-native";
import type React from "react";
import { View, type ViewProps } from "react-native";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
@@ -18,7 +18,7 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
});
})
});
queryClient.invalidateQueries({
queryKey: ["resumeItems"],
});
@@ -51,9 +51,9 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
<RoundButton
fillColor={allPlayed ? "primary" : undefined}
icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => {
onPress={async () => {
console.log(allPlayed);
await markAsPlayedStatus(!allPlayed)
await markAsPlayedStatus(!allPlayed);
}}
size={props.size}
/>

View File

@@ -1,9 +1,10 @@
import React, { useMemo } from "react";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem";
import { useTranslation } from "react-i18next";
interface Server {
address: string;
@@ -29,7 +30,7 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
return (
<View>
<ListGroup title={t("server.previous_servers")} className="mt-4">
<ListGroup title={t("server.previous_servers")} className='mt-4'>
{previousServers.map((s) => (
<ListItem
key={s.address}
@@ -43,7 +44,7 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
setPreviousServers("[]");
}}
title={t("server.clear_button")}
textColor="red"
textColor='red'
/>
</ListGroup>
</View>

View File

@@ -1,5 +1,5 @@
import React from "react";
import { View, StyleSheet } from "react-native";
import type React from "react";
import { StyleSheet, View } from "react-native";
import { AnimatedCircularProgress } from "react-native-circular-progress";
type ProgressCircleProps = {

View File

@@ -1,15 +1,18 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View, ViewProps } from "react-native";
import { Badge } from "./Badge";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useQuery } from "@tanstack/react-query";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
import {useMemo} from "react";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useMemo } from "react";
import { View, type ViewProps } from "react-native";
import { Badge } from "./Badge";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -18,21 +21,21 @@ interface Props extends ViewProps {
export const Ratings: React.FC<Props> = ({ item, ...props }) => {
if (!item) return null;
return (
<View className="flex flex-row items-center mt-2 space-x-2" {...props}>
<View className='flex flex-row items-center mt-2 space-x-2' {...props}>
{item.OfficialRating && (
<Badge text={item.OfficialRating} variant="gray" />
<Badge text={item.OfficialRating} variant='gray' />
)}
{item.CommunityRating && (
<Badge
text={item.CommunityRating.toFixed(1)}
variant="gray"
iconLeft={<Ionicons name="star" size={14} color="gold" />}
variant='gray'
iconLeft={<Ionicons name='star' size={14} color='gold' />}
/>
)}
{item.CriticRating && (
<Badge
text={item.CriticRating}
variant="gray"
variant='gray'
iconLeft={
<Image
source={
@@ -52,9 +55,9 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
);
};
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult | TvDetails | MovieDetails }> = ({
result,
}) => {
export const JellyserrRatings: React.FC<{
result: MovieResult | TvResult | TvDetails | MovieDetails;
}> = ({ result }) => {
const { jellyseerrApi, getMediaType } = useJellyseerr();
const mediaType = useMemo(() => getMediaType(result), [result]);
@@ -76,14 +79,14 @@ export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult | TvDet
!!result.voteCount ||
(data?.criticsRating && !!data?.criticsScore) ||
(data?.audienceRating && !!data?.audienceScore)) && (
<View className="flex flex-row flex-wrap space-x-1">
<View className='flex flex-row flex-wrap space-x-1'>
{data?.criticsRating && !!data?.criticsScore && (
<Badge
text={`${data.criticsScore}%`}
variant="gray"
variant='gray'
iconLeft={
<Image
className="mr-1"
className='mr-1'
source={
data?.criticsRating === "Rotten"
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
@@ -100,10 +103,10 @@ export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult | TvDet
{data?.audienceRating && !!data?.audienceScore && (
<Badge
text={`${data.audienceScore}%`}
variant="gray"
variant='gray'
iconLeft={
<Image
className="mr-1"
className='mr-1'
source={
data?.audienceRating === "Spilled"
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
@@ -120,10 +123,10 @@ export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult | TvDet
{!!result.voteCount && (
<Badge
text={`${Math.round(result.voteAverage * 10)}%`}
variant="gray"
variant='gray'
iconLeft={
<Image
className="mr-1"
className='mr-1'
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
style={{
width: 14,

View File

@@ -1,12 +1,12 @@
import { useHaptic } from "@/hooks/useHaptic";
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { PropsWithChildren } from "react";
import type { PropsWithChildren } from "react";
import {
Platform,
TouchableOpacity,
TouchableOpacityProps,
type TouchableOpacityProps,
} from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends TouchableOpacityProps {
onPress?: () => void;

View File

@@ -1,18 +1,23 @@
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
import {
ScrollView,
TouchableOpacity,
View,
type ViewProps,
} from "react-native";
import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader";
import { HorizontalScroll } from "./common/HorrizontalScroll";
import { Text } from "./common/Text";
import { TouchableItemRouter } from "./common/TouchableItemRouter";
import { useTranslation } from "react-i18next";
interface SimilarItemsProps extends ViewProps {
itemId?: string | null;
@@ -39,17 +44,19 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
return response.data.Items || [];
},
enabled: !!api && !!user?.Id,
staleTime: Infinity,
staleTime: Number.POSITIVE_INFINITY,
});
const movies = useMemo(
() => similarItems?.filter((i) => i.Type === "Movie") || [],
[similarItems]
[similarItems],
);
return (
<View {...props}>
<Text className="px-4 text-lg font-bold mb-2">{t("item_card.similar_items")}</Text>
<Text className='px-4 text-lg font-bold mb-2'>
{t("item_card.similar_items")}
</Text>
<HorizontalScroll
data={movies}
loading={isLoading}
@@ -59,7 +66,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
<TouchableItemRouter
key={idx}
item={item}
className="flex flex-col w-28"
className='flex flex-col w-28'
>
<View>
<MoviePoster item={item} />

View File

@@ -1,10 +1,10 @@
import { tc } from "@/utils/textTools";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
@@ -25,7 +25,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
const selectedSubtitleSteam = useMemo(
() => subtitleStreams?.find((x) => x.Index === selected),
[subtitleStreams, selected]
[subtitleStreams, selected],
);
if (subtitleStreams?.length === 0) return null;
@@ -34,7 +34,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
return (
<View
className="flex col shrink justify-start place-self-start items-start"
className='flex col shrink justify-start place-self-start items-start'
style={{
minWidth: 60,
maxWidth: 200,
@@ -42,12 +42,12 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col " {...props}>
<Text numberOfLines={1} className="opacity-50 mb-1 text-xs">
<View className='flex flex-col ' {...props}>
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
{t("item_card.subtitles")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className=" ">
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text className=' '>
{selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: t("item_card.none")}
@@ -57,8 +57,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}

View File

@@ -1,4 +1,4 @@
import { Text, type TextProps, StyleSheet } from "react-native";
import { StyleSheet, Text, type TextProps } from "react-native";
export type ThemedTextProps = TextProps & {
lightColor?: string;

View File

@@ -1,5 +1,5 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { View } from "react-native";
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
@@ -7,7 +7,7 @@ export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
<>
{item.UserData?.Played === false &&
(item.Type === "Movie" || item.Type === "Episode") && (
<View className="bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45"></View>
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45'></View>
)}
</>
);

View File

@@ -1,10 +1,12 @@
import * as React from 'react';
import renderer from 'react-test-renderer';
import * as React from "react";
import renderer from "react-test-renderer";
import { ThemedText } from '../ThemedText';
import { ThemedText } from "../ThemedText";
it(`renders correctly`, () => {
const tree = renderer.create(<ThemedText>Snapshot test!</ThemedText>).toJSON();
const tree = renderer
.create(<ThemedText>Snapshot test!</ThemedText>)
.toJSON();
expect(tree).toMatchSnapshot();
});

View File

@@ -1,5 +1,5 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {}

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { StyleSheet, View, ViewProps } from "react-native";
import { StyleSheet, View, type ViewProps } from "react-native";
const getItemStyle = (index: number, numColumns: number) => {
const alignItems = (() => {
@@ -29,7 +29,7 @@ export const ColumnItem = ({
...rest
}: ColumnItemProps) => {
return (
<View className="flex flex-col mb-2 p-4" style={{ width: "33.3%" }}>
<View className='flex flex-col mb-2 p-4' style={{ width: "33.3%" }}>
<View
className={`
`}

View File

@@ -1,13 +1,13 @@
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
import React, {
PropsWithChildren,
ReactNode,
type PropsWithChildren,
type ReactNode,
useEffect,
useState,
} from "react";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
interface Props<T> {
data: T[];
@@ -21,7 +21,7 @@ interface Props<T> {
multiple?: boolean;
}
const Dropdown = <T extends unknown>({
const Dropdown = <T,>({
data,
disabled,
placeholderText,
@@ -47,10 +47,10 @@ const Dropdown = <T extends unknown>({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{typeof title === "string" ? (
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">{title}</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{selected?.length !== undefined
? selected.map(titleExtractor).join(",")
: placeholderText}
@@ -63,8 +63,8 @@ const Dropdown = <T extends unknown>({
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
@@ -88,10 +88,10 @@ const Dropdown = <T extends unknown>({
}
return [
...prev.filter(
(p) => keyExtractor(p) !== keyExtractor(item)
(p) => keyExtractor(p) !== keyExtractor(item),
),
];
})
});
}}
>
<DropdownMenu.ItemTitle>
@@ -107,7 +107,7 @@ const Dropdown = <T extends unknown>({
{titleExtractor(item)}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)
),
)}
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@@ -1,14 +1,14 @@
import { Text } from "@/components/common/Text";
import { Ionicons } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur";
import { useRouter } from "expo-router";
import {
Platform,
TouchableOpacity,
TouchableOpacityProps,
type TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "@/components/common/Text";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { BlurView, BlurViewProps } from "expo-blur";
interface Props extends BlurViewProps {
background?: "blur" | "transparent";
@@ -31,13 +31,13 @@ export const HeaderBackButton: React.FC<Props> = ({
<BlurView
{...props}
intensity={100}
className="overflow-hidden rounded-full p-2"
className='overflow-hidden rounded-full p-2'
>
<Ionicons
className="drop-shadow-2xl"
name="arrow-back"
className='drop-shadow-2xl'
name='arrow-back'
size={24}
color="white"
color='white'
/>
</BlurView>
</TouchableOpacity>
@@ -46,14 +46,14 @@ export const HeaderBackButton: React.FC<Props> = ({
return (
<TouchableOpacity
onPress={() => router.back()}
className=" bg-neutral-800/80 rounded-full p-2"
className=' bg-neutral-800/80 rounded-full p-2'
{...touchableOpacityProps}
>
<Ionicons
className="drop-shadow-2xl"
name="arrow-back"
className='drop-shadow-2xl'
name='arrow-back'
size={24}
color="white"
color='white'
/>
</TouchableOpacity>
);

View File

@@ -1,6 +1,6 @@
import { FlashList, FlashListProps } from "@shopify/flash-list";
import { FlashList, type FlashListProps } from "@shopify/flash-list";
import React, { forwardRef, useImperativeHandle, useRef } from "react";
import { View, ViewStyle } from "react-native";
import { View, type ViewStyle } from "react-native";
import { Text } from "./Text";
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
@@ -44,7 +44,7 @@ export const HorizontalScroll = forwardRef<
noItemsText,
...props
}: HorizontalScrollProps<T>,
ref: React.ForwardedRef<HorizontalScrollRef>
ref: React.ForwardedRef<HorizontalScrollRef>,
) => {
const flashListRef = useRef<FlashList<T>>(null);
@@ -66,16 +66,16 @@ export const HorizontalScroll = forwardRef<
item: T;
index: number;
}) => (
<View className="mr-2">
<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 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>
);
}
@@ -95,8 +95,8 @@ export const HorizontalScroll = forwardRef<
}}
keyExtractor={keyExtractor}
ListEmptyComponent={() => (
<View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">
<View className='flex-1 justify-center items-center'>
<Text className='text-center text-gray-500'>
{noItemsText || "No data available"}
</Text>
</View>
@@ -104,5 +104,5 @@ export const HorizontalScroll = forwardRef<
{...props}
/>
);
}
},
);

View File

@@ -1,13 +1,14 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
import type {
BaseItemDto,
BaseItemDtoQueryResult,
} from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList, FlashListProps } from "@shopify/flash-list";
import { FlashList, type FlashListProps } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { t } from "i18next";
import { useAtom } from "jotai";
import React, { useEffect, useMemo } from "react";
import { View, ViewStyle } from "react-native";
import { View, type ViewStyle } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -15,7 +16,6 @@ import Animated, {
} from "react-native-reanimated";
import { Loader } from "../Loader";
import { Text } from "./Text";
import { t } from "i18next";
interface HorizontalScrollProps
extends Omit<FlashListProps<BaseItemDto>, "renderItem" | "data" | "style"> {
@@ -70,7 +70,7 @@ export function InfiniteHorizontalScroll({
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0
0,
);
if (accumulatedItems < totalItems) {
@@ -118,7 +118,7 @@ export function InfiniteHorizontalScroll({
<FlashList
data={flatData}
renderItem={({ item, index }) => (
<View className="mr-2">
<View className='mr-2'>
<React.Fragment>{renderItem(item, index)}</React.Fragment>
</View>
)}
@@ -136,8 +136,10 @@ export function InfiniteHorizontalScroll({
}}
showsHorizontalScrollIndicator={false}
ListEmptyComponent={
<View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">{t("item_card.no_data_available")}</Text>
<View className='flex-1 justify-center items-center'>
<Text className='text-center text-gray-500'>
{t("item_card.no_data_available")}
</Text>
</View>
}
{...props}

View File

@@ -1,32 +1,35 @@
import React from "react";
import {Platform, TextInput, TextInputProps, TouchableOpacity} from "react-native";
import {
Platform,
TextInput,
type TextInputProps,
TouchableOpacity,
} from "react-native";
export function Input(props: TextInputProps) {
const { style, ...otherProps } = props;
const inputRef = React.useRef<TextInput>(null);
return Platform.isTV ? (
<TouchableOpacity
onFocus={() => inputRef?.current?.focus?.()}
>
<TextInput
ref={inputRef}
className="p-4 rounded-xl bg-neutral-900"
allowFontScaling={false}
style={[{ color: "white" }, style]}
placeholderTextColor={"#9CA3AF"}
clearButtonMode="while-editing"
{...otherProps}
/>
</TouchableOpacity>
<TouchableOpacity onFocus={() => inputRef?.current?.focus?.()}>
<TextInput
ref={inputRef}
className='p-4 rounded-xl bg-neutral-900'
allowFontScaling={false}
style={[{ color: "white" }, style]}
placeholderTextColor={"#9CA3AF"}
clearButtonMode='while-editing'
{...otherProps}
/>
</TouchableOpacity>
) : (
<TextInput
ref={inputRef}
className="p-4 rounded-xl bg-neutral-900"
className='p-4 rounded-xl bg-neutral-900'
allowFontScaling={false}
style={[{ color: "white" }, style]}
placeholderTextColor={"#9CA3AF"}
clearButtonMode="while-editing"
clearButtonMode='while-editing'
{...otherProps}
/>
)
);
}

View File

@@ -1,11 +1,11 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getItemImage } from "@/utils/getItemImage";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image, ImageProps } from "expo-image";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image, type ImageProps } from "expo-image";
import { useAtom } from "jotai";
import {FC, useMemo} from "react";
import { View, ViewProps } from "react-native";
import { type FC, useMemo } from "react";
import { View, type ViewProps } from "react-native";
interface Props extends ImageProps {
item: BaseItemDto;
@@ -52,13 +52,13 @@ export const ItemImage: FC<Props> = ({
if (!source?.uri)
return (
<View
{...props as ViewProps}
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
{...(props as ViewProps)}
className='flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900'
>
<Ionicons
name="image-outline"
name='image-outline'
size={24}
color="white"
color='white'
style={{ opacity: 0.4 }}
/>
</View>

View File

@@ -1,16 +1,20 @@
import { useRouter, useSegments } from "expo-router";
import React, { PropsWithChildren, useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
import * as ContextMenu from "@/components/ContextMenu";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import {
Permission,
hasPermission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { useRouter, useSegments } from "expo-router";
import type React from "react";
import { type PropsWithChildren, useCallback, useMemo } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
interface Props extends TouchableOpacityProps {
result?: MovieResult | TvResult | MovieDetails | TvDetails;
@@ -46,16 +50,13 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
);
}, [jellyseerrApi, jellyseerrUser]);
const request = useCallback(
() => {
if (!result) return;
requestMedia(mediaTitle, {
mediaId: result.id,
mediaType,
})
},
[jellyseerrApi, result]
);
const request = useCallback(() => {
if (!result) return;
requestMedia(mediaTitle, {
mediaId: result.id,
mediaType,
});
}, [jellyseerrApi, result]);
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (
@@ -75,7 +76,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
releaseYear,
canRequest,
posterSrc,
mediaType
mediaType,
},
});
}}
@@ -91,10 +92,10 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
loop={false}
key={"content"}
>
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
<ContextMenu.Label key='label-1'>Actions</ContextMenu.Label>
{canRequest && mediaType === MediaType.MOVIE && (
<ContextMenu.Item
key="item-1"
key='item-1'
onSelect={() => {
if (autoApprove) {
request();
@@ -102,7 +103,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
}}
shouldDismissMenuOnSelect
>
<ContextMenu.ItemTitle key="item-1-title">
<ContextMenu.ItemTitle key='item-1-title'>
Request
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
@@ -116,7 +117,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
light: "purple",
},
}}
androidIconName="download"
androidIconName='download'
/>
</ContextMenu.Item>
)}

View File

@@ -4,16 +4,16 @@ import { View } from "react-native";
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
if (!url)
return (
<View className="p-4 rounded-xl overflow-hidden ">
<View className="w-full aspect-video rounded-xl overflow-hidden border border-neutral-800"></View>
<View className='p-4 rounded-xl overflow-hidden '>
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'></View>
</View>
);
return (
<View className="p-4 rounded-xl overflow-hidden ">
<View className='p-4 rounded-xl overflow-hidden '>
<Image
source={{ uri: url }}
className="w-full aspect-video rounded-xl overflow-hidden border border-neutral-800"
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
/>
</View>
);

View File

@@ -1,11 +1,11 @@
import React from "react";
import { Platform, TextProps } from "react-native";
import { UITextView } from "react-native-uitextview";
import { Platform, type TextProps } from "react-native";
import { Text as RNText } from "react-native";
import { UITextView } from "react-native-uitextview";
export function Text(
props: TextProps & {
uiTextView?: boolean;
}
},
) {
const { style, ...otherProps } = props;
if (Platform.isTV)

View File

@@ -1,13 +1,13 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useFavorite } from "@/hooks/useFavorite";
import {
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useActionSheet } from "@expo/react-native-action-sheet";
import type {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter, useSegments } from "expo-router";
import { PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
@@ -15,7 +15,7 @@ interface Props extends TouchableOpacityProps {
export const itemRouter = (
item: BaseItemDto | BaseItemPerson,
from: string
from: string,
) => {
if ("CollectionType" in item && item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`;
@@ -58,12 +58,24 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item);
const from = segments[2];
const showActionSheet = useCallback(() => {
if (!(item.Type === "Movie" || item.Type === "Episode" || item.Type === "Series")) return;
const options = ["Mark as Played", "Mark as Not Played", isFavorite ? "Unmark as Favorite" : "Mark as Favorite", "Cancel"];
if (
!(
item.Type === "Movie" ||
item.Type === "Episode" ||
item.Type === "Series"
)
)
return;
const options = [
"Mark as Played",
"Mark as Not Played",
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
"Cancel",
];
const cancelButtonIndex = 3;
showActionSheetWithOptions(
@@ -77,9 +89,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
} else if (selectedIndex === 1) {
await markAsPlayedStatus(false);
} else if (selectedIndex === 2) {
toggleFavorite()
toggleFavorite();
}
}
},
);
}, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);

View File

@@ -1,5 +1,5 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {
index: number;
@@ -12,18 +12,18 @@ export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
style={{
width: "32%",
}}
className="flex flex-col"
className='flex flex-col'
{...props}
>
<View
style={{
aspectRatio: "10/15",
}}
className="w-full bg-neutral-800 mb-2 rounded-lg"
className='w-full bg-neutral-800 mb-2 rounded-lg'
></View>
<View className="h-2 bg-neutral-800 rounded-full mb-1"></View>
<View className="h-2 bg-neutral-800 rounded-full mb-1"></View>
<View className="h-2 bg-neutral-800 rounded-full mb-2 w-1/2"></View>
<View className='h-2 bg-neutral-800 rounded-full mb-1'></View>
<View className='h-2 bg-neutral-800 rounded-full mb-1'></View>
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2'></View>
</View>
);
};

View File

@@ -2,7 +2,7 @@ import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
import { JobStatus } from "@/utils/optimize-server";
import type { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -14,9 +14,9 @@ import {
ActivityIndicator,
Platform,
TouchableOpacity,
TouchableOpacityProps,
type TouchableOpacityProps,
View,
ViewProps,
type ViewProps,
} from "react-native";
import { toast } from "sonner-native";
import { Button } from "../Button";
@@ -33,22 +33,22 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
const { processes } = useDownload();
if (processes?.length === 0)
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'>
{t("home.downloads.active_download")}
</Text>
<Text className="opacity-50">
<Text className='opacity-50'>
{t("home.downloads.no_active_downloads")}
</Text>
</View>
);
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold mb-2">
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold mb-2'>
{t("home.downloads.active_downloads")}
</Text>
<View className="space-y-2">
<View className='space-y-2'>
{processes?.map((p: JobStatus) => (
<DownloadCard key={p.item.Id} process={p} />
))}
@@ -89,7 +89,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
} else {
FFmpegKitProvider.FFmpegKit.cancel(Number(id));
setProcesses((prev: any[]) =>
prev.filter((p: { id: string }) => p.id !== id)
prev.filter((p: { id: string }) => p.id !== id),
);
}
},
@@ -117,7 +117,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
return (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
{...props}
>
{(process.status === "optimizing" ||
@@ -133,10 +133,10 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}}
></View>
)}
<View className="px-3 py-1.5 flex flex-col w-full">
<View className="flex flex-row items-center w-full">
<View className='px-3 py-1.5 flex flex-col w-full'>
<View className='flex flex-row items-center w-full'>
{base64Image && (
<View className="w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4">
<View className='w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4'>
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
@@ -149,51 +149,51 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
/>
</View>
)}
<View className="shrink mb-1">
<Text className="text-xs opacity-50">{process.item.Type}</Text>
<Text className="font-semibold shrink">{process.item.Name}</Text>
<Text className="text-xs opacity-50">
<View className='shrink mb-1'>
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
<Text className='font-semibold shrink'>{process.item.Name}</Text>
<Text className='text-xs opacity-50'>
{process.item.ProductionYear}
</Text>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
{process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<Text className="text-xs">{process.progress.toFixed(0)}%</Text>
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
)}
{process.speed && (
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
<Text className='text-xs'>{process.speed?.toFixed(2)}x</Text>
)}
{eta(process) && (
<Text className="text-xs">
<Text className='text-xs'>
{t("home.downloads.eta", { eta: eta(process) })}
</Text>
)}
</View>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
<Text className="text-xs capitalize">{process.status}</Text>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
<Text className='text-xs capitalize'>{process.status}</Text>
</View>
</View>
<TouchableOpacity
disabled={cancelJobMutation.isPending}
onPress={() => cancelJobMutation.mutate(process.id)}
className="ml-auto"
className='ml-auto'
>
{cancelJobMutation.isPending ? (
<ActivityIndicator size="small" color="white" />
<ActivityIndicator size='small' color='white' />
) : (
<Ionicons name="close" size={24} color="red" />
<Ionicons name='close' size={24} color='red' />
)}
</TouchableOpacity>
</View>
{process.status === "completed" && (
<View className="flex flex-row mt-4 space-x-4">
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className="w-full"
className='w-full'
>
Download now
</Button>

View File

@@ -1,8 +1,9 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useEffect, useMemo, useState } from "react";
import { TextProps } from "react-native";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import type { TextProps } from "react-native";
interface DownloadSizeProps extends TextProps {
items: BaseItemDto[];
@@ -39,7 +40,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
return (
<>
<Text className="text-xs text-neutral-500" {...props}>
<Text className='text-xs text-neutral-500' {...props}>
{sizeText}
</Text>
</>

View File

@@ -1,22 +1,27 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useHaptic } from "@/hooks/useHaptic";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import {
ActionSheetProvider,
useActionSheet,
} from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { useCallback, useMemo } from "react";
import {
TouchableOpacity,
type TouchableOpacityProps,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { Image } from "expo-image";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/common/Text";
import { runtimeTicksToSeconds } from "@/utils/time";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
interface EpisodeCardProps extends TouchableOpacityProps {
item: BaseItemDto;
@@ -67,7 +72,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
// Cancelled
break;
}
}
},
);
}, [showActionSheetWithOptions, handleDeleteFile]);
@@ -76,27 +81,27 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
onPress={handleOpenFile}
onLongPress={showActionSheet}
key={item.Id}
className="flex flex-col mb-4"
className='flex flex-col mb-4'
>
<View className="flex flex-row items-start mb-2">
<View className="mr-2">
<ContinueWatchingPoster size="small" item={item} useEpisodePoster />
<View className='flex flex-row items-start mb-2'>
<View className='mr-2'>
<ContinueWatchingPoster size='small' item={item} useEpisodePoster />
</View>
<View className="shrink">
<Text numberOfLines={2} className="">
<View className='shrink'>
<Text numberOfLines={2} className=''>
{item.Name}
</Text>
<Text numberOfLines={1} className="text-xs text-neutral-500">
<Text numberOfLines={1} className='text-xs text-neutral-500'>
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
</Text>
<Text className="text-xs text-neutral-500">
<Text className='text-xs text-neutral-500'>
{runtimeTicksToSeconds(item.RunTimeTicks)}
</Text>
<DownloadSize items={[item]} />
</View>
</View>
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
<Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
{item.Overview}
</Text>
</TouchableOpacity>
@@ -105,7 +110,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
// Wrap the parent component with ActionSheetProvider
export const EpisodeCardWithActionSheet: React.FC<EpisodeCardProps> = (
props
props,
) => (
<ActionSheetProvider>
<EpisodeCard {...props} />

View File

@@ -1,10 +1,11 @@
import { useHaptic } from "@/hooks/useHaptic";
import {
ActionSheetProvider,
useActionSheet,
} from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useHaptic } from "@/hooks/useHaptic";
import React, { useCallback, useMemo } from "react";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import { DownloadSize } from "@/components/downloads/DownloadSize";
@@ -69,14 +70,14 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
// Cancelled
break;
}
}
},
);
}, [showActionSheetWithOptions, handleDeleteFile]);
return (
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
{base64Image ? (
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
<View className='w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
@@ -89,16 +90,16 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
/>
</View>
) : (
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
<View className='w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
<Ionicons
name="image-outline"
name='image-outline'
size={24}
color="gray"
className="self-center mt-16"
color='gray'
className='self-center mt-16'
/>
</View>
)}
<View className="w-28">
<View className='w-28'>
<ItemCardText item={item} />
</View>
<DownloadSize items={[item]} />

View File

@@ -1,16 +1,17 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {TouchableOpacity, View} from "react-native";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { router } from "expo-router";
import type React from "react";
import { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import { Text } from "../common/Text";
import React, {useCallback, useMemo} from "react";
import {storage} from "@/utils/mmkv";
import {Image} from "expo-image";
import {Ionicons} from "@expo/vector-icons";
import {router} from "expo-router";
import {DownloadSize} from "@/components/downloads/DownloadSize";
import {useDownload} from "@/providers/DownloadProvider";
import {useActionSheet} from "@expo/react-native-action-sheet";
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const { deleteItems } = useDownload();
const { showActionSheetWithOptions } = useActionSheet();
@@ -18,16 +19,14 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
return storage.getString(items[0].SeriesId!);
}, []);
const deleteSeries = useCallback(
async () => deleteItems(items),
[items]
);
const deleteSeries = useCallback(async () => deleteItems(items), [items]);
const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"];
const destructiveButtonIndex = 0;
showActionSheetWithOptions({
showActionSheetWithOptions(
{
options,
destructiveButtonIndex,
},
@@ -35,7 +34,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
if (selectedIndex == destructiveButtonIndex) {
deleteSeries();
}
}
},
);
}, [showActionSheetWithOptions, deleteSeries]);
@@ -45,7 +44,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
onLongPress={showActionSheet}
>
{base64Image ? (
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
<View className='w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
@@ -56,25 +55,26 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
resizeMode: "cover",
}}
/>
<View
className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center absolute bottom-1 right-1">
<Text className="text-xs font-bold">{items.length}</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center absolute bottom-1 right-1'>
<Text className='text-xs font-bold'>{items.length}</Text>
</View>
</View>
) : (
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
<View className='w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
<Ionicons
name="image-outline"
name='image-outline'
size={24}
color="gray"
className="self-center mt-16"
color='gray'
className='self-center mt-16'
/>
</View>
)}
<View className="w-28 mt-2 flex flex-col">
<Text numberOfLines={2} className="">{items[0].SeriesName}</Text>
<Text className="text-xs opacity-50">{items[0].ProductionYear}</Text>
<View className='w-28 mt-2 flex flex-col'>
<Text numberOfLines={2} className=''>
{items[0].SeriesName}
</Text>
<Text className='text-xs opacity-50'>{items[0].ProductionYear}</Text>
<DownloadSize items={items} />
</View>
</TouchableOpacity>

View File

@@ -2,7 +2,7 @@ import { Text } from "@/components/common/Text";
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { FilterSheet } from "./FilterSheet";
interface FilterButtonProps<T> extends ViewProps {
@@ -68,16 +68,16 @@ export const FilterButton = <T,>({
</Text>
{icon === "filter" ? (
<Ionicons
name="filter"
name='filter'
size={14}
color="white"
color='white'
style={{ opacity: 0.5 }}
/>
) : (
<FontAwesome
name="sort"
name='sort'
size={14}
color="white"
color='white'
style={{ opacity: 0.5 }}
/>
)}

View File

@@ -1,25 +1,25 @@
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
type BottomSheetBackdropProps,
BottomSheetFlatList,
BottomSheetModal,
BottomSheetScrollView,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text } from "@/components/common/Text";
import { StyleSheet, TouchableOpacity, View, ViewProps } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useTranslation } from "react-i18next";
import {
StyleSheet,
TouchableOpacity,
View,
type ViewProps,
} from "react-native";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { useTranslation } from "react-i18next";
interface Props<T> extends ViewProps {
open: boolean;
@@ -130,7 +130,7 @@ export const FilterSheet = <T,>({
appearsOnIndex={0}
/>
),
[]
[],
);
return (
@@ -153,18 +153,20 @@ export const FilterSheet = <T,>({
flex: 1,
}}
>
<View className="px-4 mt-2 mb-8">
<Text className="font-bold text-2xl">{title}</Text>
<Text className="mb-2 text-neutral-500">{t("search.x_items", {count: _data?.length})}</Text>
<View className='px-4 mt-2 mb-8'>
<Text className='font-bold text-2xl'>{title}</Text>
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
{showSearch && (
<Input
placeholder={t("search.search")}
className="my-2"
className='my-2'
value={search}
onChangeText={(text) => {
setSearch(text);
}}
returnKeyType="done"
returnKeyType='done'
/>
)}
<View
@@ -172,7 +174,7 @@ export const FilterSheet = <T,>({
borderRadius: 20,
overflow: "hidden",
}}
className="mb-4 flex flex-col rounded-xl overflow-hidden"
className='mb-4 flex flex-col rounded-xl overflow-hidden'
>
{renderData?.map((item, index) => (
<View key={index}>
@@ -185,20 +187,20 @@ export const FilterSheet = <T,>({
}, 250);
}
}}
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text>{renderItemLabel(item)}</Text>
{values.some((i) => i === item) ? (
<Ionicons name="radio-button-on" size={24} color="white" />
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name="radio-button-off" size={24} color="white" />
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className="h-1 divide-neutral-700 "
className='h-1 divide-neutral-700 '
></View>
</View>
))}

View File

@@ -5,7 +5,7 @@ import {
} from "@/utils/atoms/filters";
import { Ionicons } from "@expo/vector-icons";
import { useAtom } from "jotai";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
interface Props extends TouchableOpacityProps {}
@@ -29,10 +29,10 @@ export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
setSelectedTags([]);
setSelectedYears([]);
}}
className="bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1"
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
{...props}
>
<Ionicons name="close" size={20} color="white" />
<Ionicons name='close' size={20} color='white' />
</TouchableOpacity>
);
};

View File

@@ -13,154 +13,154 @@ import { ScrollingCollectionList } from "./ScrollingCollectionList";
import heart from "@/assets/icons/heart.fill.png";
type FavoriteTypes =
| "Series"
| "Movie"
| "Episode"
| "Video"
| "BoxSet"
| "Playlist";
| "Series"
| "Movie"
| "Episode"
| "Video"
| "BoxSet"
| "Playlist";
type EmptyState = Record<FavoriteTypes, boolean>;
export const Favorites = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [emptyState, setEmptyState] = useState<EmptyState>({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [emptyState, setEmptyState] = useState<EmptyState>({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
const fetchFavoritesByType = useCallback(
async (itemType: BaseItemKind) => {
const response = await getItemsApi(api as Api).getItems({
userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
excludeLocationTypes: ["Virtual"],
enableTotalRecordCount: false,
limit: 20,
includeItemTypes: [itemType],
});
const items = response.data.Items || [];
const fetchFavoritesByType = useCallback(
async (itemType: BaseItemKind) => {
const response = await getItemsApi(api as Api).getItems({
userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
excludeLocationTypes: ["Virtual"],
enableTotalRecordCount: false,
limit: 20,
includeItemTypes: [itemType],
});
const items = response.data.Items || [];
// Update empty state for this specific type
setEmptyState((prev) => ({
...prev,
[itemType as FavoriteTypes]: items.length === 0,
}));
// Update empty state for this specific type
setEmptyState((prev) => ({
...prev,
[itemType as FavoriteTypes]: items.length === 0,
}));
return items;
},
[api, user],
);
return items;
},
[api, user],
);
// Reset empty state when component mounts or dependencies change
useEffect(() => {
setEmptyState({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
}, [api, user]);
// Reset empty state when component mounts or dependencies change
useEffect(() => {
setEmptyState({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
}, [api, user]);
// Check if all categories that have been loaded are empty
const areAllEmpty = () => {
const loadedCategories = Object.values(emptyState);
return (
loadedCategories.length > 0 &&
loadedCategories.every((isEmpty) => isEmpty)
);
};
// Check if all categories that have been loaded are empty
const areAllEmpty = () => {
const loadedCategories = Object.values(emptyState);
return (
loadedCategories.length > 0 &&
loadedCategories.every((isEmpty) => isEmpty)
);
};
const fetchFavoriteSeries = useCallback(
() => fetchFavoritesByType("Series"),
[fetchFavoritesByType],
);
const fetchFavoriteMovies = useCallback(
() => fetchFavoritesByType("Movie"),
[fetchFavoritesByType],
);
const fetchFavoriteEpisodes = useCallback(
() => fetchFavoritesByType("Episode"),
[fetchFavoritesByType],
);
const fetchFavoriteVideos = useCallback(
() => fetchFavoritesByType("Video"),
[fetchFavoritesByType],
);
const fetchFavoriteBoxsets = useCallback(
() => fetchFavoritesByType("BoxSet"),
[fetchFavoritesByType],
);
const fetchFavoritePlaylists = useCallback(
() => fetchFavoritesByType("Playlist"),
[fetchFavoritesByType],
);
const fetchFavoriteSeries = useCallback(
() => fetchFavoritesByType("Series"),
[fetchFavoritesByType],
);
const fetchFavoriteMovies = useCallback(
() => fetchFavoritesByType("Movie"),
[fetchFavoritesByType],
);
const fetchFavoriteEpisodes = useCallback(
() => fetchFavoritesByType("Episode"),
[fetchFavoritesByType],
);
const fetchFavoriteVideos = useCallback(
() => fetchFavoritesByType("Video"),
[fetchFavoritesByType],
);
const fetchFavoriteBoxsets = useCallback(
() => fetchFavoritesByType("BoxSet"),
[fetchFavoritesByType],
);
const fetchFavoritePlaylists = useCallback(
() => fetchFavoritesByType("Playlist"),
[fetchFavoritesByType],
);
return (
<View className="flex flex-co gap-y-4">
{areAllEmpty() && (
<View className="flex-1 items-center justify-center py-12">
<Image
className={"w-10 h-10 mb-4"}
style={{ tintColor: Colors.primary, resizeMode: "contain" }}
source={heart}
/>
<Text className="text-xl font-semibold text-white mb-2">
{t("favorites.noDataTitle")}
</Text>
<Text className="text-base text-white/70 text-center max-w-xs px-4">
{t("favorites.noData")}
</Text>
</View>
)}
<ScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]}
title={t("favorites.series")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation="vertical"
/>
<ScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
title={t("favorites.videos")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
title={t("favorites.playlists")}
hideIfEmpty
/>
</View>
);
return (
<View className='flex flex-co gap-y-4'>
{areAllEmpty() && (
<View className='flex-1 items-center justify-center py-12'>
<Image
className={"w-10 h-10 mb-4"}
style={{ tintColor: Colors.primary, resizeMode: "contain" }}
source={heart}
/>
<Text className='text-xl font-semibold text-white mb-2'>
{t("favorites.noDataTitle")}
</Text>
<Text className='text-base text-white/70 text-center max-w-xs px-4'>
{t("favorites.noData")}
</Text>
</View>
)}
<ScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]}
title={t("favorites.series")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
/>
<ScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
title={t("favorites.videos")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
title={t("favorites.playlists")}
hideIfEmpty
/>
</View>
);
};

View File

@@ -3,14 +3,14 @@ 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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react";
import { Dimensions, View, ViewProps } from "react-native";
import { Dimensions, View, type ViewProps } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
runOnJS,
@@ -18,7 +18,7 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import Carousel, {
ICarouselInstance,
type ICarouselInstance,
Pagination,
} from "react-native-reanimated-carousel";
import { itemRouter } from "../common/TouchableItemRouter";
@@ -88,13 +88,13 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
if (!popularItems) return null;
return (
<View className="flex flex-col items-center mt-2" {...props}>
<View className='flex flex-col items-center mt-2' {...props}>
<Carousel
ref={ref}
autoPlay={false}
loop={true}
snapEnabled={true}
mode="parallax"
mode='parallax'
modeConfig={{
parallaxScrollingScale: 0.86,
parallaxScrollingOffset: 100,
@@ -178,9 +178,9 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
style={{
opacity: opacity,
}}
className="px-4"
className='px-4'
>
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
<View className='relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800'>
<Image
source={{
uri,
@@ -192,7 +192,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
overflow: "hidden",
}}
/>
<View className="absolute bottom-0 left-0 w-full h-24 p-4 flex items-center">
<View className='absolute bottom-0 left-0 w-full h-24 p-4 flex items-center'>
<Image
source={{
uri: logoUri,

View File

@@ -1,17 +1,17 @@
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
useQuery,
type QueryFunction,
type QueryKey,
useQuery,
} from "@tanstack/react-query";
import { ScrollView, View, ViewProps } from "react-native";
import { useTranslation } from "react-i18next";
import { ScrollView, View, type ViewProps } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import SeriesPoster from "../posters/SeriesPoster";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps {
title?: string | null;
@@ -47,12 +47,12 @@ export const ScrollingCollectionList: React.FC<Props> = ({
return (
<View {...props}>
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
{title}
</Text>
{isLoading === false && data?.length === 0 && (
<View className="px-4">
<Text className="text-neutral-500">{t("home.no_items")}</Text>
<View className='px-4'>
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
</View>
)}
{isLoading ? (
@@ -62,19 +62,19 @@ export const ScrollingCollectionList: React.FC<Props> = ({
`}
>
{[1, 2, 3].map((i) => (
<View className="w-44" key={i}>
<View className="bg-neutral-900 h-24 w-full rounded-md mb-1"></View>
<View className="rounded-md overflow-hidden mb-1 self-start">
<View className='w-44' key={i}>
<View className='bg-neutral-900 h-24 w-full rounded-md mb-1'></View>
<View className='rounded-md overflow-hidden mb-1 self-start'>
<Text
className="text-neutral-900 bg-neutral-900 rounded-md"
className='text-neutral-900 bg-neutral-900 rounded-md'
numberOfLines={1}
>
Nisi mollit voluptate amet.
</Text>
</View>
<View className="rounded-md overflow-hidden self-start mb-1">
<View className='rounded-md overflow-hidden self-start mb-1'>
<Text
className="text-neutral-900 bg-neutral-900 text-xs rounded-md "
className='text-neutral-900 bg-neutral-900 text-xs rounded-md '
numberOfLines={1}
>
Lorem ipsum
@@ -85,7 +85,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
</View>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
<View className='px-4 flex flex-row'>
{data?.map((item) => (
<TouchableItemRouter
item={item}

View File

@@ -1,15 +1,15 @@
import {TouchableOpacity, View} from "react-native";
import {Text} from "@/components/common/Text";
import { Text } from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { TouchableOpacity, View } from "react-native";
interface StepperProps {
value: number,
disabled?: boolean,
step: number,
min: number,
max: number,
onUpdate: (value: number) => void,
appendValue?: string,
value: number;
disabled?: boolean;
step: number;
min: number;
max: number;
onUpdate: (value: number) => void;
appendValue?: string;
}
export const Stepper: React.FC<StepperProps> = ({
@@ -19,33 +19,35 @@ export const Stepper: React.FC<StepperProps> = ({
min,
max,
onUpdate,
appendValue
appendValue,
}) => {
return (
<DisabledSetting
disabled={disabled === true}
showText={false}
className="flex flex-row items-center"
className='flex flex-row items-center'
>
<TouchableOpacity
onPress={() => onUpdate(Math.max(min, value - step))}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
className='w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center'
>
<Text>-</Text>
</TouchableOpacity>
<Text
className={
"w-auto h-8 bg-neutral-800 py-2 px-1 flex items-center justify-center" + (appendValue ? "first-letter:px-2" : "")
"w-auto h-8 bg-neutral-800 py-2 px-1 flex items-center justify-center" +
(appendValue ? "first-letter:px-2" : "")
}
>
{value}{appendValue}
{value}
{appendValue}
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
className='w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center'
onPress={() => onUpdate(Math.min(max, value + step))}
>
<Text>+</Text>
</TouchableOpacity>
</DisabledSetting>
)
}
);
};

View File

@@ -1,11 +1,11 @@
import { View, ViewProps } from "react-native";
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import React from "react";
import { FlashList } from "@shopify/flash-list";
import { Text } from "@/components/common/Text";
import PersonPoster from "@/components/jellyseerr/PersonPoster";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { FlashList } from "@shopify/flash-list";
import type React from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
const CastSlide: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps
@@ -15,12 +15,14 @@ const CastSlide: React.FC<
details?.credits?.cast &&
details?.credits?.cast?.length > 0 && (
<View {...props}>
<Text className="text-lg font-bold mb-2 px-4">{t("jellyseerr.cast")}</Text>
<Text className='text-lg font-bold mb-2 px-4'>
{t("jellyseerr.cast")}
</Text>
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
data={details?.credits.cast}
ItemSeparatorComponent={() => <View className="w-2" />}
ItemSeparatorComponent={() => <View className='w-2' />}
estimatedItemSize={15}
keyExtractor={(item) => item?.id?.toString()}
contentContainerStyle={{ paddingHorizontal: 16 }}

View File

@@ -1,15 +1,15 @@
import { View, ViewProps } from "react-native";
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Text } from "@/components/common/Text";
import { useMemo } from "react";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { uniqBy } from "lodash";
import { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import CountryFlag from "react-native-country-flag";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { uniqBy } from "lodash";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import CountryFlag from "react-native-country-flag";
interface Release {
certification: string;
@@ -30,12 +30,12 @@ const Facts: React.FC<
> = ({ title, facts, ...props }) =>
facts &&
facts?.length > 0 && (
<View className="flex flex-col justify-between py-2" {...props}>
<Text className="font-bold text-start">{title}</Text>
<View className='flex flex-col justify-between py-2' {...props}>
<Text className='font-bold text-start'>{title}</Text>
<View className="flex flex-col items-end">
<View className='flex flex-col items-end'>
{facts.map((f, idx) =>
typeof f === "string" ? <Text key={idx}>{f}</Text> : f
typeof f === "string" ? <Text key={idx}>{f}</Text> : f,
)}
</View>
</View>
@@ -50,15 +50,19 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
const DetailFacts: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, className, ...props }) => {
const { jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const {
jellyseerrUser,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { t } = useTranslation();
const releases = useMemo(
() =>
(details as MovieDetails)?.releases?.results.find(
(r: TmdbRelease) => r.iso_3166_1 === region
(r: TmdbRelease) => r.iso_3166_1 === region,
)?.release_dates as TmdbRelease["release_dates"],
[details]
[details],
);
// Release date types:
@@ -72,9 +76,9 @@ const DetailFacts: React.FC<
() =>
uniqBy(
releases?.filter((r: Release) => r.type > 2 && r.type < 6),
"type"
"type",
),
[releases]
[releases],
);
const firstAirDate = useMemo(() => {
@@ -82,7 +86,7 @@ const DetailFacts: React.FC<
if (firstAirDate) {
return new Date(firstAirDate).toLocaleDateString(
`${locale}-${region}`,
dateOpts
dateOpts,
);
}
}, [details]);
@@ -93,7 +97,7 @@ const DetailFacts: React.FC<
if (nextAirDate && firstAirDate !== nextAirDate) {
return new Date(nextAirDate).toLocaleDateString(
`${locale}-${region}`,
dateOpts
dateOpts,
);
}
}, [details]);
@@ -102,26 +106,26 @@ const DetailFacts: React.FC<
() =>
(details as MovieDetails)?.revenue?.toLocaleString?.(
`${locale}-${region}`,
{ style: "currency", currency: "USD" }
{ style: "currency", currency: "USD" },
),
[details]
[details],
);
const budget = useMemo(
() =>
(details as MovieDetails)?.budget?.toLocaleString?.(
`${locale}-${region}`,
{ style: "currency", currency: "USD" }
{ style: "currency", currency: "USD" },
),
[details]
[details],
);
const streamingProviders = useMemo(
() =>
details?.watchProviders?.find(
(provider) => provider.iso_3166_1 === region
(provider) => provider.iso_3166_1 === region,
)?.flatrate,
[details]
[details],
);
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
@@ -129,15 +133,15 @@ const DetailFacts: React.FC<
const spokenLanguage = useMemo(
() =>
details?.spokenLanguages.find(
(lng) => lng.iso_639_1 === details.originalLanguage
(lng) => lng.iso_639_1 === details.originalLanguage,
)?.name,
[details]
[details],
);
return (
details && (
<View className="p-4">
<Text className="text-lg font-bold">{t("jellyseerr.details")}</Text>
<View className='p-4'>
<Text className='text-lg font-bold'>{t("jellyseerr.details")}</Text>
<View
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
{...props}
@@ -148,30 +152,30 @@ const DetailFacts: React.FC<
fact={(details as TvDetails)?.originalName}
/>
{details.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID
) && <Fact title={t("jellyseerr.series_type")} fact="Anime" />}
(keyword) => keyword.id === ANIME_KEYWORD_ID,
) && <Fact title={t("jellyseerr.series_type")} fact='Anime' />}
<Facts
title={t("jellyseerr.release_dates")}
facts={filteredReleases?.map?.((r: Release, idx) => (
<View key={idx} className="flex flex-row space-x-2 items-center">
<View key={idx} className='flex flex-row space-x-2 items-center'>
{r.type === 3 ? (
// Theatrical
<Ionicons name="ticket" size={16} color="white" />
<Ionicons name='ticket' size={16} color='white' />
) : r.type === 4 ? (
// Digital
<Ionicons name="cloud" size={16} color="white" />
<Ionicons name='cloud' size={16} color='white' />
) : (
// Physical
<MaterialCommunityIcons
name="record-circle-outline"
name='record-circle-outline'
size={16}
color="white"
color='white'
/>
)}
<Text>
{new Date(r.release_date).toLocaleDateString(
`${locale}-${region}`,
dateOpts
dateOpts,
)}
</Text>
</View>
@@ -181,11 +185,14 @@ const DetailFacts: React.FC<
<Fact title={t("jellyseerr.next_air_date")} fact={nextAirDate} />
<Fact title={t("jellyseerr.revenue")} fact={revenue} />
<Fact title={t("jellyseerr.budget")} fact={budget} />
<Fact title={t("jellyseerr.original_language")} fact={spokenLanguage} />
<Fact
title={t("jellyseerr.original_language")}
fact={spokenLanguage}
/>
<Facts
title={t("jellyseerr.production_country")}
facts={details?.productionCountries?.map((n, idx) => (
<View key={idx} className="flex flex-row items-center space-x-2">
<View key={idx} className='flex flex-row items-center space-x-2'>
<CountryFlag isoCode={n.iso_3166_1} size={10} />
<Text>{n.name}</Text>
</View>
@@ -194,10 +201,13 @@ const DetailFacts: React.FC<
<Facts
title={t("jellyseerr.studios")}
facts={uniqBy(details?.productionCompanies, "name")?.map(
(n) => n.name
(n) => n.name,
)}
/>
<Facts title={t("jellyseerr.network")}facts={networks?.map((n) => n.name)} />
<Facts
title={t("jellyseerr.network")}
facts={networks?.map((n) => n.name)}
/>
<Facts
title={t("jellyseerr.currently_streaming_on")}
facts={streamingProviders?.map((s) => s.name)}

View File

@@ -1,14 +1,17 @@
import Discover from "@/components/jellyseerr/discover/Discover";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {
import type {
MovieResult,
PersonResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
import React, {useMemo, useState} from "react";
import { View, ViewProps } from "react-native";
import { orderBy, uniqBy } from "lodash";
import type React from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import {
useAnimatedReaction,
useAnimatedStyle,
@@ -20,8 +23,6 @@ import JellyseerrPoster from "../posters/JellyseerrPoster";
import { LoadingSkeleton } from "../search/LoadingSkeleton";
import { SearchItemWrapper } from "../search/SearchItemWrapper";
import PersonPoster from "./PersonPoster";
import { useTranslation } from "react-i18next";
import {orderBy, uniqBy} from "lodash";
interface Props extends ViewProps {
searchQuery: string;
@@ -30,15 +31,15 @@ interface Props extends ViewProps {
}
export enum JellyseerrSearchSort {
DEFAULT,
VOTE_COUNT_AND_AVERAGE,
POPULARITY
DEFAULT = 0,
VOTE_COUNT_AND_AVERAGE = 1,
POPULARITY = 2,
}
export const JellyserrIndexPage: React.FC<Props> = ({
searchQuery,
sortType,
order
order,
}) => {
const { jellyseerrApi } = useJellyseerr();
const opacity = useSharedValue(1);
@@ -57,19 +58,24 @@ export const JellyserrIndexPage: React.FC<Props> = ({
const {
data: jellyseerrResults,
isFetching: f2,
isLoading: l2
isLoading: l2,
} = useReactNavigationQuery({
queryKey: ["search", "jellyseerr", "results", searchQuery],
queryFn: async () => {
const params = {
query: new URLSearchParams(searchQuery || "").toString()
}
query: new URLSearchParams(searchQuery || "").toString(),
};
return await Promise.all([
jellyseerrApi?.search({...params, page: 1}),
jellyseerrApi?.search({...params, page: 2}),
jellyseerrApi?.search({...params, page: 3}),
jellyseerrApi?.search({...params, page: 4})
]).then(all => uniqBy(all.flatMap(v => v?.results || []), "id"))
jellyseerrApi?.search({ ...params, page: 1 }),
jellyseerrApi?.search({ ...params, page: 2 }),
jellyseerrApi?.search({ ...params, page: 3 }),
jellyseerrApi?.search({ ...params, page: 4 }),
]).then((all) =>
uniqBy(
all.flatMap((v) => v?.results || []),
"id",
),
);
},
enabled: !!jellyseerrApi && searchQuery.length > 0,
});
@@ -82,57 +88,66 @@ export const JellyserrIndexPage: React.FC<Props> = ({
} else {
opacity.value = withTiming(0, { duration: 200 });
}
}
},
);
const sortingType = useMemo(
() => {
if (!sortType) return;
switch (Number(JellyseerrSearchSort[sortType])) {
case JellyseerrSearchSort.VOTE_COUNT_AND_AVERAGE:
return ["voteCount", "voteAverage"];
case JellyseerrSearchSort.POPULARITY:
return ["voteCount", "popularity"]
default:
return undefined
}
},
[sortType, order]
)
const sortingType = useMemo(() => {
if (!sortType) return;
switch (Number(JellyseerrSearchSort[sortType])) {
case JellyseerrSearchSort.VOTE_COUNT_AND_AVERAGE:
return ["voteCount", "voteAverage"];
case JellyseerrSearchSort.POPULARITY:
return ["voteCount", "popularity"];
default:
return undefined;
}
}, [sortType, order]);
const jellyseerrMovieResults = useMemo(
() =>
orderBy(
jellyseerrResults?.filter((r) => r.mediaType === MediaType.MOVIE) as MovieResult[],
sortingType || [m => m.title.toLowerCase() == searchQuery.toLowerCase()],
order || "desc"
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.MOVIE,
) as MovieResult[],
sortingType || [
(m) => m.title.toLowerCase() == searchQuery.toLowerCase(),
],
order || "desc",
),
[jellyseerrResults, sortingType, order]
[jellyseerrResults, sortingType, order],
);
const jellyseerrTvResults = useMemo(
() =>
orderBy(
jellyseerrResults?.filter((r) => r.mediaType === MediaType.TV) as TvResult[],
sortingType || [t => t.name.toLowerCase() == searchQuery.toLowerCase()],
order || "desc"
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.TV,
) as TvResult[],
sortingType || [
(t) => t.name.toLowerCase() == searchQuery.toLowerCase(),
],
order || "desc",
),
[jellyseerrResults, sortingType, order]
[jellyseerrResults, sortingType, order],
);
const jellyseerrPersonResults = useMemo(
() =>
orderBy(
jellyseerrResults?.filter((r) => r.mediaType === "person") as PersonResult[],
sortingType || [p => p.name.toLowerCase() == searchQuery.toLowerCase()],
order || "desc"
jellyseerrResults?.filter(
(r) => r.mediaType === "person",
) as PersonResult[],
sortingType || [
(p) => p.name.toLowerCase() == searchQuery.toLowerCase(),
],
order || "desc",
),
[jellyseerrResults, sortingType, order]
[jellyseerrResults, sortingType, order],
);
if (!searchQuery.length)
return (
<View className="flex flex-col">
<View className='flex flex-col'>
<Discover sliders={jellyseerrDiscoverSettings} />
</View>
);
@@ -149,10 +164,10 @@ export const JellyserrIndexPage: React.FC<Props> = ({
!l1 &&
!l2 && (
<View>
<Text className="text-center text-lg font-bold mt-4">
<Text className='text-center text-lg font-bold mt-4'>
{t("search.no_results_found_for")}
</Text>
<Text className="text-xs text-purple-600 text-center">
<Text className='text-xs text-purple-600 text-center'>
"{searchQuery}"
</Text>
</View>
@@ -178,7 +193,7 @@ export const JellyserrIndexPage: React.FC<Props> = ({
items={jellyseerrPersonResults}
renderItem={(item: PersonResult) => (
<PersonPoster
className="mr-2"
className='mr-2'
key={item.id}
id={item.id.toString()}
name={item.name}

View File

@@ -1,37 +1,32 @@
import {useMemo} from "react";
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
import {Feather, MaterialCommunityIcons} from "@expo/vector-icons";
import {View, ViewProps} from "react-native";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { Feather, MaterialCommunityIcons } from "@expo/vector-icons";
import { useMemo } from "react";
import { View, type ViewProps } from "react-native";
const JellyseerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
mediaType,
className,
...props
}) => {
const JellyseerrMediaIcon: React.FC<
{ mediaType: "tv" | "movie" } & ViewProps
> = ({ mediaType, className, ...props }) => {
const style = useMemo(
() => mediaType === MediaType.MOVIE
? 'bg-blue-600/90 border-blue-400/40'
: 'bg-purple-600/90 border-purple-400/40',
[mediaType]
() =>
mediaType === MediaType.MOVIE
? "bg-blue-600/90 border-blue-400/40"
: "bg-purple-600/90 border-purple-400/40",
[mediaType],
);
return (
mediaType &&
<View className={`${className} border ${style} rounded-full p-1`} {...props}>
{mediaType === MediaType.MOVIE ? (
<MaterialCommunityIcons
name="movie-open"
size={16}
color="white"
/>
) : (
<Feather
size={16}
name="tv"
color="white"
/>
)}
</View>
)
}
mediaType && (
<View
className={`${className} border ${style} rounded-full p-1`}
{...props}
>
{mediaType === MediaType.MOVIE ? (
<MaterialCommunityIcons name='movie-open' size={16} color='white' />
) : (
<Feather size={16} name='tv' color='white' />
)}
</View>
)
);
};
export default JellyseerrMediaIcon;
export default JellyseerrMediaIcon;

View File

@@ -1,7 +1,7 @@
import {useEffect, useState} from "react";
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
import {MaterialCommunityIcons} from "@expo/vector-icons";
import {TouchableOpacity, View, ViewProps} from "react-native";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useEffect, useState } from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native";
interface Props {
mediaStatus?: MediaStatus;
@@ -15,7 +15,8 @@ const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
onPress,
...props
}) => {
const [badgeIcon, setBadgeIcon] = useState<keyof typeof MaterialCommunityIcons.glyphMap>();
const [badgeIcon, setBadgeIcon] =
useState<keyof typeof MaterialCommunityIcons.glyphMap>();
const [badgeStyle, setBadgeStyle] = useState<string>();
// Match similar to what Jellyseerr is currently using
@@ -23,49 +24,54 @@ const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
useEffect(() => {
switch (mediaStatus) {
case MediaStatus.PROCESSING:
setBadgeStyle('bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100');
setBadgeIcon('clock');
setBadgeStyle(
"bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100",
);
setBadgeIcon("clock");
break;
case MediaStatus.AVAILABLE:
setBadgeStyle('bg-purple-500 border-green-400 ring-green-400 text-green-100');
setBadgeIcon('check')
setBadgeStyle(
"bg-purple-500 border-green-400 ring-green-400 text-green-100",
);
setBadgeIcon("check");
break;
case MediaStatus.PENDING:
setBadgeStyle('bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100');
setBadgeIcon('bell')
setBadgeStyle(
"bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100",
);
setBadgeIcon("bell");
break;
case MediaStatus.BLACKLISTED:
setBadgeStyle('bg-red-500 border-white-400 ring-white-400 text-white');
setBadgeIcon('eye-off')
setBadgeStyle("bg-red-500 border-white-400 ring-white-400 text-white");
setBadgeIcon("eye-off");
break;
case MediaStatus.PARTIALLY_AVAILABLE:
setBadgeStyle('bg-green-500 border-green-400 ring-green-400 text-green-100');
setBadgeStyle(
"bg-green-500 border-green-400 ring-green-400 text-green-100",
);
setBadgeIcon("minus");
break;
default:
if (showRequestIcon) {
setBadgeStyle('bg-green-600');
setBadgeIcon("plus")
setBadgeStyle("bg-green-600");
setBadgeIcon("plus");
}
break;
}
}, [mediaStatus, showRequestIcon, setBadgeStyle, setBadgeIcon])
}, [mediaStatus, showRequestIcon, setBadgeStyle, setBadgeIcon]);
return (
badgeIcon &&
<TouchableOpacity onPress={onPress} disabled={onPress == undefined}>
badgeIcon && (
<TouchableOpacity onPress={onPress} disabled={onPress == undefined}>
<View
className={`${badgeStyle ?? 'bg-purple-600'} rounded-full h-6 w-6 flex items-center justify-center ${props.className}`}
{...props}
className={`${badgeStyle ?? "bg-purple-600"} rounded-full h-6 w-6 flex items-center justify-center ${props.className}`}
{...props}
>
<MaterialCommunityIcons
name={badgeIcon}
size={18}
color="white"
/>
<MaterialCommunityIcons name={badgeIcon} size={18} color='white' />
</View>
</TouchableOpacity>
)
}
</TouchableOpacity>
)
);
};
export default JellyseerrStatusIcon;
export default JellyseerrStatusIcon;

View File

@@ -1,29 +1,27 @@
import React, {
PropsWithChildren,
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { Text } from "@/components/common/Text";
import { FlashList } from "@shopify/flash-list";
import { useFocusEffect } from "expo-router";
import type React from "react";
import {
type PropsWithChildren,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {Dimensions, View, ViewProps} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { Text } from "@/components/common/Text";
import { Dimensions, View, type ViewProps } from "react-native";
import { Animated } from "react-native";
import { FlashList } from "@shopify/flash-list";
import {useFocusEffect} from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const ANIMATION_ENTER = 250;
const ANIMATION_EXIT = 250;
const BACKDROP_DURATION = 5000;
type Render = React.ComponentType<any>
| React.ReactElement
| null
| undefined;
type Render = React.ComponentType<any> | React.ReactElement | null | undefined;
interface Props<T> {
data: T[]
data: T[];
images: string[];
logo?: React.ReactElement;
HeaderContent?: () => React.ReactElement;
@@ -34,7 +32,7 @@ interface Props<T> {
onEndReached?: (() => void) | null | undefined;
}
const ParallaxSlideShow = <T extends unknown>({
const ParallaxSlideShow = <T,>({
data,
images,
logo,
@@ -45,8 +43,7 @@ const ParallaxSlideShow = <T extends unknown>({
keyExtractor,
onEndReached,
...props
}: PropsWithChildren<Props<T> & ViewProps>
) => {
}: PropsWithChildren<Props<T> & ViewProps>) => {
const insets = useSafeAreaInsets();
const [currentIndex, setCurrentIndex] = useState(0);
@@ -59,7 +56,7 @@ const ParallaxSlideShow = <T extends unknown>({
duration: ANIMATION_ENTER,
useNativeDriver: true,
}),
[fadeAnim]
[fadeAnim],
);
const exitAnimation = useCallback(
@@ -69,7 +66,7 @@ const ParallaxSlideShow = <T extends unknown>({
duration: ANIMATION_EXIT,
useNativeDriver: true,
}),
[fadeAnim]
[fadeAnim],
);
useEffect(() => {
@@ -77,31 +74,35 @@ const ParallaxSlideShow = <T extends unknown>({
enterAnimation().start();
const intervalId = setInterval(() => {
Animated.sequence([
enterAnimation(),
exitAnimation()
]).start(() => {
Animated.sequence([enterAnimation(), exitAnimation()]).start(() => {
fadeAnim.setValue(0);
setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
})
});
}, BACKDROP_DURATION);
return () => {
clearInterval(intervalId)
clearInterval(intervalId);
};
}
}, [fadeAnim, images, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
}, [
fadeAnim,
images,
enterAnimation,
exitAnimation,
setCurrentIndex,
currentIndex,
]);
return (
<View
className="flex-1 relative"
className='flex-1 relative'
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className="flex-1 opacity-100"
className='flex-1 opacity-100'
headerHeight={300}
onEndReached={onEndReached}
headerImage={
@@ -120,9 +121,9 @@ const ParallaxSlideShow = <T extends unknown>({
}
logo={logo}
>
<View className="flex flex-col space-y-4 px-4">
<View className="flex flex-row justify-between w-full">
<View className="flex flex-col w-full">
<View className='flex flex-col space-y-4 px-4'>
<View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-full'>
{HeaderContent && HeaderContent()}
</View>
</View>
@@ -131,30 +132,30 @@ const ParallaxSlideShow = <T extends unknown>({
<FlashList
data={data}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
No results
</Text>
</View>
}
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
ListHeaderComponent={
<Text className="text-lg font-bold my-2">{listHeader}</Text>
<Text className='text-lg font-bold my-2'>{listHeader}</Text>
}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
//@ts-ignore
renderItem={({ item, index}) => renderItem(item, index)}
renderItem={({ item, index }) => renderItem(item, index)}
keyExtractor={keyExtractor}
numColumns={3}
estimatedItemSize={214}
ItemSeparatorComponent={() => <View className="h-2 w-2" />}
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
/>
</View>
</View>
</ParallaxScrollView>
</View>
);
}
};
export default ParallaxSlideShow;
export default ParallaxSlideShow;

View File

@@ -1,15 +1,15 @@
import {TouchableOpacity, View, ViewProps} from "react-native";
import React from "react";
import {Text} from "@/components/common/Text";
import { Text } from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
import {useRouter, useSegments} from "expo-router";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useRouter, useSegments } from "expo-router";
import type React from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native";
interface Props {
id: string
posterPath?: string
name: string
subName?: string
id: string;
posterPath?: string;
name: string;
subName?: string;
}
const PersonPoster: React.FC<Props & ViewProps> = ({
@@ -19,24 +19,28 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
subName,
...props
}) => {
const {jellyseerrApi} = useJellyseerr();
const { jellyseerrApi } = useJellyseerr();
const router = useRouter();
const segments = useSegments();
const from = segments[2];
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (
<TouchableOpacity onPress={() => router.push(`/(auth)/(tabs)/${from}/jellyseerr/person/${id}`)}>
<View className="flex flex-col w-28" {...props}>
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/(tabs)/${from}/jellyseerr/person/${id}`)
}
>
<View className='flex flex-col w-28' {...props}>
<Poster
id={id}
url={jellyseerrApi?.imageProxy(posterPath, 'w600_and_h900_bestv2')}
url={jellyseerrApi?.imageProxy(posterPath, "w600_and_h900_bestv2")}
/>
<Text className="mt-2">{name}</Text>
{subName && <Text className="text-xs opacity-50">{subName}</Text>}
<Text className='mt-2'>{name}</Text>
{subName && <Text className='text-xs opacity-50'>{subName}</Text>}
</View>
</TouchableOpacity>
)
}
);
};
export default PersonPoster;
export default PersonPoster;

View File

@@ -1,21 +1,30 @@
import React, {forwardRef, useCallback, useMemo, useState} from "react";
import {View, ViewProps} from "react-native";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {useQuery} from "@tanstack/react-query";
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
import { Button } from "@/components/Button";
import Dropdown from "@/components/common/Dropdown";
import {QualityProfile, RootFolder, Tag} from "@/utils/jellyseerr/server/api/servarr/base";
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {BottomSheetModalMethods} from "@gorhom/bottom-sheet/lib/typescript/types";
import {Button} from "@/components/Button";
import {Text} from "@/components/common/Text";
import { Text } from "@/components/common/Text";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type {
QualityProfile,
RootFolder,
Tag,
} from "@/utils/jellyseerr/server/api/servarr/base";
import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
import { useQuery } from "@tanstack/react-query";
import React, { forwardRef, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
interface Props {
id: number;
title: string,
requestBody?: MediaRequestBody,
title: string;
requestBody?: MediaRequestBody;
type: MediaType;
isAnime?: boolean;
is4k?: boolean;
@@ -23,216 +32,252 @@ interface Props {
onDismiss?: () => void;
}
const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps, 'id'>>(({
id,
title,
requestBody,
type,
isAnime = false,
onRequested,
onDismiss,
...props
}, ref) => {
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr();
const [requestOverrides, setRequestOverrides] =
useState<MediaRequestBody>({
const RequestModal = forwardRef<
BottomSheetModalMethods,
Props & Omit<ViewProps, "id">
>(
(
{
id,
title,
requestBody,
type,
isAnime = false,
onRequested,
onDismiss,
...props
},
ref,
) => {
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
mediaId: Number(id),
mediaType: type,
userId: jellyseerrUser?.id
userId: jellyseerrUser?.id,
});
const { t } = useTranslation();
const { t } = useTranslation();
const {data: serviceSettings} = useQuery({
queryKey: ["jellyseerr", "request", type, 'service'],
queryFn: async () => jellyseerrApi?.service(type == 'movie' ? 'radarr' : 'sonarr'),
enabled: !!jellyseerrApi && !!jellyseerrUser,
refetchOnMount: 'always'
});
const { data: serviceSettings } = useQuery({
queryKey: ["jellyseerr", "request", type, "service"],
queryFn: async () =>
jellyseerrApi?.service(type == "movie" ? "radarr" : "sonarr"),
enabled: !!jellyseerrApi && !!jellyseerrUser,
refetchOnMount: "always",
});
const {data: users} = useQuery({
queryKey: ["jellyseerr", "users"],
queryFn: async () => jellyseerrApi?.user({take: 1000, sort: 'displayname'}),
enabled: !!jellyseerrApi && !!jellyseerrUser,
refetchOnMount: 'always'
});
const { data: users } = useQuery({
queryKey: ["jellyseerr", "users"],
queryFn: async () =>
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
enabled: !!jellyseerrApi && !!jellyseerrUser,
refetchOnMount: "always",
});
const defaultService = useMemo(
() => serviceSettings?.find?.(v => v.isDefault),
[serviceSettings]
);
const defaultService = useMemo(
() => serviceSettings?.find?.((v) => v.isDefault),
[serviceSettings],
);
const {data: defaultServiceDetails} = useQuery({
queryKey: ["jellyseerr", "request", type, 'service', 'details', defaultService?.id],
queryFn: async () => {
setRequestOverrides((prev) => ({
...prev,
serverId: defaultService?.id
}))
return jellyseerrApi?.serviceDetails(type === 'movie' ? 'radarr' : 'sonarr', defaultService!!.id)
},
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService,
refetchOnMount: 'always',
});
const { data: defaultServiceDetails } = useQuery({
queryKey: [
"jellyseerr",
"request",
type,
"service",
"details",
defaultService?.id,
],
queryFn: async () => {
setRequestOverrides((prev) => ({
...prev,
serverId: defaultService?.id,
}));
return jellyseerrApi?.serviceDetails(
type === "movie" ? "radarr" : "sonarr",
defaultService!.id,
);
},
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService,
refetchOnMount: "always",
});
const defaultProfile: QualityProfile = useMemo(
() => defaultServiceDetails?.profiles
.find(p =>
p.id === (isAnime ? defaultServiceDetails.server?.activeAnimeProfileId : defaultServiceDetails.server?.activeProfileId)
),
[defaultServiceDetails]
);
const defaultProfile: QualityProfile = useMemo(
() =>
defaultServiceDetails?.profiles.find(
(p) =>
p.id ===
(isAnime
? defaultServiceDetails.server?.activeAnimeProfileId
: defaultServiceDetails.server?.activeProfileId),
),
[defaultServiceDetails],
);
const defaultFolder: RootFolder = useMemo(
() => defaultServiceDetails?.rootFolders
.find(f =>
f.path === (isAnime ? defaultServiceDetails?.server.activeAnimeDirectory : defaultServiceDetails.server?.activeDirectory)
),
[defaultServiceDetails]
);
const defaultFolder: RootFolder = useMemo(
() =>
defaultServiceDetails?.rootFolders.find(
(f) =>
f.path ===
(isAnime
? defaultServiceDetails?.server.activeAnimeDirectory
: defaultServiceDetails.server?.activeDirectory),
),
[defaultServiceDetails],
);
const defaultTags: Tag[] = useMemo(
() => {
const tags = defaultServiceDetails?.tags
.filter(t =>
const defaultTags: Tag[] = useMemo(() => {
const tags =
defaultServiceDetails?.tags.filter((t) =>
(isAnime
? defaultServiceDetails?.server.activeAnimeTags
: defaultServiceDetails?.server.activeTags
)?.includes(t.id)
) ?? []
return tags
},
[defaultServiceDetails]
);
)?.includes(t.id),
) ?? [];
return tags;
}, [defaultServiceDetails]);
const seasonTitle = useMemo(
() => {
const seasonTitle = useMemo(() => {
if (requestBody?.seasons && requestBody?.seasons?.length > 1) {
return t("jellyseerr.season_all")
return t("jellyseerr.season_all");
}
return t("jellyseerr.season_number", {season_number: requestBody?.seasons})
},
[requestBody?.seasons]
);
return t("jellyseerr.season_number", {
season_number: requestBody?.seasons,
});
}, [requestBody?.seasons]);
const request = useCallback(() => {requestMedia(
seasonTitle ? `${title}, ${seasonTitle}` : title,
{
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
profileId: defaultProfile.id,
rootFolder: defaultFolder.path,
tags: defaultTags.map(t => t.id),
...requestBody,
...requestOverrides
},
onRequested
)
}, [requestBody, requestOverrides, defaultProfile, defaultFolder, defaultTags]);
const request = useCallback(() => {
requestMedia(
seasonTitle ? `${title}, ${seasonTitle}` : title,
{
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
profileId: defaultProfile.id,
rootFolder: defaultFolder.path,
tags: defaultTags.map((t) => t.id),
...requestBody,
...requestOverrides,
},
onRequested,
);
}, [
requestBody,
requestOverrides,
defaultProfile,
defaultFolder,
defaultTags,
]);
const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`;
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
return (
<BottomSheetModal
ref={ref}
enableDynamicSizing
enableDismissOnClose
onDismiss={onDismiss}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(sheetProps: BottomSheetBackdropProps) =>
<BottomSheetBackdrop
{...sheetProps}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
}
>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">{t("jellyseerr.advanced")}</Text>
{seasonTitle &&
<Text className="text-neutral-300">{seasonTitle}</Text>
}
return (
<BottomSheetModal
ref={ref}
enableDynamicSizing
enableDismissOnClose
onDismiss={onDismiss}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(sheetProps: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...sheetProps}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.advanced")}
</Text>
{seasonTitle && (
<Text className='text-neutral-300'>{seasonTitle}</Text>
)}
</View>
<View className='flex flex-col space-y-2'>
{defaultService && defaultServiceDetails && users && (
<>
<Dropdown
data={defaultServiceDetails.profiles}
titleExtractor={(item) => item.name}
placeholderText={
requestOverrides.profileName || defaultProfile.name
}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.quality_profile")}
onSelected={(item) =>
item &&
setRequestOverrides((prev) => ({
...prev,
profileId: item?.id,
}))
}
title={t("jellyseerr.quality_profile")}
/>
<Dropdown
data={defaultServiceDetails.rootFolders}
titleExtractor={pathTitleExtractor}
placeholderText={
defaultFolder ? pathTitleExtractor(defaultFolder) : ""
}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.root_folder")}
onSelected={(item) =>
item &&
setRequestOverrides((prev) => ({
...prev,
rootFolder: item.path,
}))
}
title={t("jellyseerr.root_folder")}
/>
<Dropdown
multiple
data={defaultServiceDetails.tags}
titleExtractor={(item) => item.label}
placeholderText={defaultTags.map((t) => t.label).join(",")}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.tags")}
onSelected={(...selected) =>
setRequestOverrides((prev) => ({
...prev,
tags: selected.map((i) => i.id),
}))
}
title={t("jellyseerr.tags")}
/>
<Dropdown
data={users}
titleExtractor={(item) => item.displayName}
placeholderText={jellyseerrUser!.displayName}
keyExtractor={(item) => item.id.toString() || ""}
label={t("jellyseerr.request_as")}
onSelected={(item) =>
item &&
setRequestOverrides((prev) => ({
...prev,
userId: item?.id,
}))
}
title={t("jellyseerr.request_as")}
/>
</>
)}
</View>
<Button className='mt-auto' onPress={request} color='purple'>
{t("jellyseerr.request_button")}
</Button>
</View>
<View className="flex flex-col space-y-2">
{(defaultService && defaultServiceDetails && users) && (
<>
<Dropdown
data={defaultServiceDetails.profiles}
titleExtractor={(item) => item.name}
placeholderText={requestOverrides.profileName || defaultProfile.name}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.quality_profile")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
profileId: item?.id
}))
}
title={t("jellyseerr.quality_profile")}
/>
<Dropdown
data={defaultServiceDetails.rootFolders}
titleExtractor={pathTitleExtractor}
placeholderText={defaultFolder ? pathTitleExtractor(defaultFolder) : ""}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.root_folder")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
rootFolder: item.path
}))}
title={t("jellyseerr.root_folder")}
/>
<Dropdown
multiple
data={defaultServiceDetails.tags}
titleExtractor={(item) => item.label}
placeholderText={defaultTags.map(t => t.label).join(",")}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.tags")}
onSelected={(...selected) =>
setRequestOverrides((prev) => ({
...prev,
tags: selected.map(i => i.id)
}))
}
title={t("jellyseerr.tags")}
/>
<Dropdown
data={users}
titleExtractor={(item) => item.displayName}
placeholderText={jellyseerrUser!!.displayName}
keyExtractor={(item) => item.id.toString() || ""}
label={t("jellyseerr.request_as")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
userId: item?.id
}))
}
title={t("jellyseerr.request_as")}
/>
</>
)
}
</View>
<Button
className="mt-auto"
onPress={request}
color="purple"
>
{t("jellyseerr.request_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
);
});
</BottomSheetView>
</BottomSheetModal>
);
},
);
export default RequestModal;
export default RequestModal;

View File

@@ -1,14 +1,15 @@
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
COMPANY_LOGO_IMAGE_FILTER,
Network,
type Network,
} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import { Studio } from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
import type { Studio } from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
import { router, useSegments } from "expo-router";
import React, { useCallback } from "react";
import { TouchableOpacity, ViewProps } from "react-native";
import type React from "react";
import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native";
const CompanySlide: React.FC<
{ data: Network[] | Studio[] } & SlideProps & ViewProps
@@ -23,7 +24,7 @@ const CompanySlide: React.FC<
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
params: { id, image, name, type: slide.type },
}),
[slide]
[slide],
);
return (
@@ -33,13 +34,13 @@ const CompanySlide: React.FC<
data={data}
keyExtractor={(item) => item.id.toString()}
renderItem={(item, index) => (
<TouchableOpacity className="mr-2" onPress={() => navigate(item)}>
<TouchableOpacity className='mr-2' onPress={() => navigate(item)}>
<GenericSlideCard
className="w-28 rounded-lg overflow-hidden border border-neutral-900 p-4"
className='w-28 rounded-lg overflow-hidden border border-neutral-900 p-4'
id={item.id.toString()}
url={jellyseerrApi?.imageProxy(
item.image,
COMPANY_LOGO_IMAGE_FILTER
COMPANY_LOGO_IMAGE_FILTER,
)}
/>
</TouchableOpacity>

View File

@@ -1,50 +1,69 @@
import React, {useMemo} from "react";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import {sortBy} from "lodash";
import MovieTvSlide from "@/components/jellyseerr/discover/MovieTvSlide";
import CompanySlide from "@/components/jellyseerr/discover/CompanySlide";
import {View} from "react-native";
import {networks} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import {studios} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
import MovieTvSlide from "@/components/jellyseerr/discover/MovieTvSlide";
import RecentRequestsSlide from "@/components/jellyseerr/discover/RecentRequestsSlide";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import { networks } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import { studios } from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
import { sortBy } from "lodash";
import type React from "react";
import { useMemo } from "react";
import { View } from "react-native";
interface Props {
sliders?: DiscoverSlider[];
}
const Discover: React.FC<Props> = ({ sliders }) => {
if (!sliders)
return;
if (!sliders) return;
const sortedSliders = useMemo(
() => sortBy(sliders.filter((s) => s.enabled), 'order', 'asc'),
[sliders]
() =>
sortBy(
sliders.filter((s) => s.enabled),
"order",
"asc",
),
[sliders],
);
return (
<View className="flex flex-col space-y-4 mb-8">
{sortedSliders.map(slide => {
<View className='flex flex-col space-y-4 mb-8'>
{sortedSliders.map((slide) => {
switch (slide.type) {
case DiscoverSliderType.RECENT_REQUESTS:
return <RecentRequestsSlide key={slide.id} slide={slide} contentContainerStyle={{paddingBottom: 16}}/>
return (
<RecentRequestsSlide
key={slide.id}
slide={slide}
contentContainerStyle={{ paddingBottom: 16 }}
/>
);
case DiscoverSliderType.NETWORKS:
return <CompanySlide key={slide.id} slide={slide} data={networks}/>
return (
<CompanySlide key={slide.id} slide={slide} data={networks} />
);
case DiscoverSliderType.STUDIOS:
return <CompanySlide key={slide.id} slide={slide} data={studios}/>
return <CompanySlide key={slide.id} slide={slide} data={studios} />;
case DiscoverSliderType.MOVIE_GENRES:
case DiscoverSliderType.TV_GENRES:
return <GenreSlide key={slide.id} slide={slide} />
return <GenreSlide key={slide.id} slide={slide} />;
case DiscoverSliderType.TRENDING:
case DiscoverSliderType.POPULAR_MOVIES:
case DiscoverSliderType.UPCOMING_MOVIES:
case DiscoverSliderType.POPULAR_TV:
case DiscoverSliderType.UPCOMING_TV:
return <MovieTvSlide key={slide.id} slide={slide} contentContainerStyle={{paddingBottom: 16}}/>
return (
<MovieTvSlide
key={slide.id}
slide={slide}
contentContainerStyle={{ paddingBottom: 16 }}
/>
);
}
})}
</View>
)
);
};
export default Discover;

View File

@@ -1,8 +1,8 @@
import React from "react";
import {StyleSheet, View, ViewProps} from "react-native";
import {Image, ImageContentFit} from "expo-image";
import {Text} from "@/components/common/Text";
import {LinearGradient} from "expo-linear-gradient";
import { Text } from "@/components/common/Text";
import { Image, type ImageContentFit } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import type React from "react";
import { StyleSheet, View, type ViewProps } from "react-native";
export const textShadowStyle = StyleSheet.create({
shadow: {
@@ -12,48 +12,59 @@ export const textShadowStyle = StyleSheet.create({
height: 1,
},
shadowOpacity: 1,
shadowRadius: .5,
shadowRadius: 0.5,
elevation: 6,
}
})
},
});
const GenericSlideCard: React.FC<{id: string; url?: string, title?: string, colors?: string[], contentFit?: ImageContentFit} & ViewProps> = ({
const GenericSlideCard: React.FC<
{
id: string;
url?: string;
title?: string;
colors?: string[];
contentFit?: ImageContentFit;
} & ViewProps
> = ({
id,
url,
title,
colors = ['#9333ea', 'transparent'],
colors = ["#9333ea", "transparent"],
contentFit = "contain",
...props
}) => (
<>
<LinearGradient
colors={colors}
start={{x: 0.5, y: 1.75}}
end={{x: 0.5, y: 0}}
className="rounded-xl"
start={{ x: 0.5, y: 1.75 }}
end={{ x: 0.5, y: 0 }}
className='rounded-xl'
>
<View className="rounded-xl" {...props}>
<View className='rounded-xl' {...props}>
<Image
key={id}
id={id}
source={url ? {uri: url} : null}
source={url ? { uri: url } : null}
cachePolicy={"memory-disk"}
contentFit={contentFit}
style={{
aspectRatio: "4/3",
}}
/>
{title &&
<View
className="absolute justify-center top-0 left-0 right-0 bottom-0 items-center"
{title && (
<View className='absolute justify-center top-0 left-0 right-0 bottom-0 items-center'>
<Text
className='text-center font-bold'
style={textShadowStyle.shadow}
>
<Text className="text-center font-bold" style={textShadowStyle.shadow}>{title}</Text>
</View>
}
{title}
</Text>
</View>
)}
</View>
</LinearGradient>
</>
);
export default GenericSlideCard;
export default GenericSlideCard;

View File

@@ -1,13 +1,14 @@
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
import { useQuery } from "@tanstack/react-query";
import { router, useSegments } from "expo-router";
import React, { useCallback } from "react";
import { TouchableOpacity, ViewProps } from "react-native";
import type React from "react";
import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native";
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const segments = useSegments();
@@ -20,7 +21,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`,
params: { type: slide.type, name: genre.name },
}),
[slide]
[slide],
);
const { data, isFetching, isLoading } = useQuery({
@@ -29,7 +30,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
return jellyseerrApi?.getGenreSliders(
slide.type == DiscoverSliderType.MOVIE_GENRES
? Endpoints.MOVIE
: Endpoints.TV
: Endpoints.TV,
);
},
enabled: !!jellyseerrApi,
@@ -43,18 +44,18 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
data={data}
keyExtractor={(item) => item.id.toString()}
renderItem={(item, index) => (
<TouchableOpacity className="mr-2" onPress={() => navigate(item)}>
<TouchableOpacity className='mr-2' onPress={() => navigate(item)}>
<GenericSlideCard
className="w-28 rounded-lg overflow-hidden border border-neutral-900"
className='w-28 rounded-lg overflow-hidden border border-neutral-900'
id={item.id.toString()}
title={item.name}
colors={['transparent', 'transparent']}
colors={["transparent", "transparent"]}
contentFit={"cover"}
url={jellyseerrApi?.imageProxy(
item.backdrops?.[0],
`w780_filter(duotone,${
genreColorMap[item.id] ?? genreColorMap[0]
})`
})`,
)}
/>
</TouchableOpacity>

View File

@@ -1,18 +1,25 @@
import React, {useMemo} from "react";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import {
DiscoverEndpoint,
type DiscoverEndpoint,
Endpoints,
useJellyseerr,
} from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { useInfiniteQuery } from "@tanstack/react-query";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
import {ViewProps} from "react-native";
import {uniqBy} from "lodash";
import { uniqBy } from "lodash";
import type React from "react";
import { useMemo } from "react";
import type { ViewProps } from "react-native";
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
slide,
...props
}) => {
const { jellyseerrApi } = useJellyseerr();
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
@@ -60,10 +67,12 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) =>
const flatData = useMemo(
() =>
uniqBy(
data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
"id"
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) => p?.results),
"id",
),
[data]
[data],
);
return (
@@ -73,14 +82,16 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) =>
{...props}
slide={slide}
data={flatData}
keyExtractor={(item) => item!!.id.toString()}
keyExtractor={(item) => item!.id.toString()}
onEndReached={() => {
if (hasNextPage)
fetchNextPage()
if (hasNextPage) fetchNextPage();
}}
renderItem={(item) =>
<JellyseerrPoster item={item as MovieResult | TvResult} key={item?.id}/>
}
renderItem={(item) => (
<JellyseerrPoster
item={item as MovieResult | TvResult}
key={item?.id}
/>
)}
/>
)
);

View File

@@ -1,20 +1,28 @@
import React from "react";
import {useQuery} from "@tanstack/react-query";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
import {ViewProps} from "react-native";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import {NonFunctionProperties} from "@/utils/jellyseerr/server/interfaces/api/common";
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
import { useQuery } from "@tanstack/react-query";
import type React from "react";
import type { ViewProps } from "react-native";
const RequestCard: React.FC<{request: MediaRequest}> = ({request}) => {
const {jellyseerrApi} = useJellyseerr();
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
const { jellyseerrApi } = useJellyseerr();
const { data: details, isLoading, isError } = useQuery({
queryKey: ["jellyseerr", "detail", request.media.mediaType, request.media.tmdbId],
const {
data: details,
isLoading,
isError,
} = useQuery({
queryKey: [
"jellyseerr",
"detail",
request.media.mediaType,
request.media.tmdbId,
],
queryFn: async () => {
return request.media.mediaType == MediaType.MOVIE
? jellyseerrApi?.movieDetails(request.media.tmdbId)
: jellyseerrApi?.tvDetails(request.media.tmdbId);
@@ -34,34 +42,47 @@ const RequestCard: React.FC<{request: MediaRequest}> = ({request}) => {
});
return (
<JellyseerrPoster horizontal showDownloadInfo item={details} mediaRequest={refreshedRequest} />
)
}
<JellyseerrPoster
horizontal
showDownloadInfo
item={details}
mediaRequest={refreshedRequest}
/>
);
};
const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const {jellyseerrApi} = useJellyseerr();
const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
slide,
...props
}) => {
const { jellyseerrApi } = useJellyseerr();
const { data: requests, isLoading, isError } = useQuery({
const {
data: requests,
isLoading,
isError,
} = useQuery({
queryKey: ["jellyseerr", "recent_requests"],
queryFn: async () => jellyseerrApi?.requests(),
queryFn: async () => jellyseerrApi?.requests(),
enabled: !!jellyseerrApi,
refetchOnMount: true,
staleTime: 0,
});
return (
requests && requests.results.length > 0 && (
requests &&
requests.results.length > 0 && (
<Slide
{...props}
slide={slide}
data={requests.results}
keyExtractor={(item) => item.id.toString()}
renderItem={(item: NonFunctionProperties<MediaRequest>) => (
<RequestCard request={item}/>
<RequestCard request={item} />
)}
/>
)
)
);
};
export default RecentRequestsSlide;
export default RecentRequestsSlide;

View File

@@ -1,11 +1,12 @@
import React, {PropsWithChildren} from "react";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import { Text } from "@/components/common/Text";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import { FlashList } from "@shopify/flash-list";
import {View, ViewProps} from "react-native";
import type { ContentStyle } from "@shopify/flash-list/src/FlashListProps";
import { t } from "i18next";
import {ContentStyle} from "@shopify/flash-list/src/FlashListProps";
import type React from "react";
import type { PropsWithChildren } from "react";
import { View, type ViewProps } from "react-native";
export interface SlideProps {
slide: DiscoverSlider;
@@ -13,17 +14,16 @@ export interface SlideProps {
}
interface Props<T> extends SlideProps {
data: T[]
renderItem: (item: T, index: number) =>
| React.ComponentType<any>
| React.ReactElement
| null
| undefined;
data: T[];
renderItem: (
item: T,
index: number,
) => React.ComponentType<any> | React.ReactElement | null | undefined;
keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null | undefined;
}
const Slide = <T extends unknown>({
const Slide = <T,>({
data,
slide,
renderItem,
@@ -31,18 +31,17 @@ const Slide = <T extends unknown>({
onEndReached,
contentContainerStyle,
...props
}: PropsWithChildren<Props<T> & ViewProps>
) => {
}: PropsWithChildren<Props<T> & ViewProps>) => {
return (
<View {...props}>
<Text className="font-bold text-lg mb-2 px-4">
<Text className='font-bold text-lg mb-2 px-4'>
{t("search." + DiscoverSliderType[slide.type].toString().toLowerCase())}
</Text>
<FlashList
horizontal
contentContainerStyle={{
paddingHorizontal: 16,
...(contentContainerStyle ? contentContainerStyle : {})
...(contentContainerStyle ? contentContainerStyle : {}),
}}
showsHorizontalScrollIndicator={false}
keyExtractor={keyExtractor}
@@ -51,7 +50,9 @@ const Slide = <T extends unknown>({
onEndReachedThreshold={1}
onEndReached={onEndReached}
//@ts-ignore
renderItem={({item, index}) => item ? renderItem(item, index) : <></>}
renderItem={({ item, index }) =>
item ? renderItem(item, index) : <></>
}
/>
</View>
);

View File

@@ -3,7 +3,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { Ionicons } from "@expo/vector-icons";
import {
import type {
BaseItemDto,
BaseItemKind,
CollectionType,
@@ -13,9 +13,9 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { TouchableOpacityProps, View } from "react-native";
import { useTranslation } from "react-i18next";
import { type TouchableOpacityProps, View } from "react-native";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { useTranslation } from "react-i18next";
interface Props extends TouchableOpacityProps {
library: BaseItemDto;
@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
api,
item: library,
}),
[library]
[library],
);
const itemType = useMemo(() => {
@@ -102,18 +102,18 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
if (settings?.libraryOptions?.display === "row") {
return (
<TouchableItemRouter item={library} className="w-full px-4">
<View className="flex flex-row items-center w-full relative ">
<TouchableItemRouter item={library} className='w-full px-4'>
<View className='flex flex-row items-center w-full relative '>
<Ionicons
name={icons[library.CollectionType!] || "folder"}
size={22}
color={"#e5e5e5"}
/>
<Text className="text-start px-4 text-neutral-200">
<Text className='text-start px-4 text-neutral-200'>
{library.Name}
</Text>
{settings?.libraryOptions?.showStats && (
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
<Text className='font-bold text-xs text-neutral-500 text-start ml-auto'>
{itemsCount} {itemTypeName}
</Text>
)}
@@ -124,8 +124,8 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
if (settings?.libraryOptions?.imageStyle === "cover") {
return (
<TouchableItemRouter item={library} className="w-full">
<View className="flex justify-center rounded-xl w-full relative border border-neutral-900 h-20 ">
<TouchableItemRouter item={library} className='w-full'>
<View className='flex justify-center rounded-xl w-full relative border border-neutral-900 h-20 '>
<View
style={{
width: "100%",
@@ -157,12 +157,12 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
/>
</View>
{settings?.libraryOptions?.showTitles && (
<Text className="font-bold text-lg text-start px-4">
<Text className='font-bold text-lg text-start px-4'>
{library.Name}
</Text>
)}
{settings?.libraryOptions?.showStats && (
<Text className="font-bold text-xs text-start px-4">
<Text className='font-bold text-xs text-start px-4'>
{itemsCount} {itemTypeName}
</Text>
)}
@@ -173,21 +173,21 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
return (
<TouchableItemRouter item={library} {...props}>
<View className="flex flex-row items-center justify-between rounded-xl w-full relative border bg-neutral-900 border-neutral-900 h-20">
<View className="flex flex-col">
<Text className="font-bold text-lg text-start px-4">
<View className='flex flex-row items-center justify-between rounded-xl w-full relative border bg-neutral-900 border-neutral-900 h-20'>
<View className='flex flex-col'>
<Text className='font-bold text-lg text-start px-4'>
{library.Name}
</Text>
{settings?.libraryOptions?.showStats && (
<Text className="font-bold text-xs text-neutral-500 text-start px-4">
<Text className='font-bold text-xs text-neutral-500 text-start px-4'>
{itemsCount} {itemTypeName}
</Text>
)}
</View>
<View className="p-2">
<View className='p-2'>
<Image
source={{ uri: url }}
className="h-full aspect-[2/1] object-cover rounded-lg overflow-hidden"
className='h-full aspect-[2/1] object-cover rounded-lg overflow-hidden'
/>
</View>
</View>

View File

@@ -1,13 +1,13 @@
import {
PropsWithChildren,
Children,
isValidElement,
type PropsWithChildren,
type ReactElement,
cloneElement,
ReactElement,
isValidElement,
} from "react";
import { StyleSheet, View, ViewProps, ViewStyle } from "react-native";
import { ListItem } from "./ListItem";
import { StyleSheet, View, type ViewProps, type ViewStyle } from "react-native";
import { Text } from "../common/Text";
import { ListItem } from "./ListItem";
interface Props extends ViewProps {
title?: string | null | undefined;
@@ -24,12 +24,12 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
return (
<View {...props}>
<Text className="ml-4 mb-1 uppercase text-[#8E8D91] text-xs">
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
{title}
</Text>
<View
style={[]}
className="flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900"
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
>
{Children.map(childrenArray, (child, index) => {
if (isValidElement<{ style?: ViewStyle }>(child)) {
@@ -38,14 +38,14 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
child.props.style,
index < childrenArray.length - 1
? styles.borderBottom
: undefined
: undefined,
),
});
}
return child;
})}
</View>
{description && <View className="pl-4 mt-1">{description}</View>}
{description && <View className='pl-4 mt-1'>{description}</View>}
</View>
);
};

View File

@@ -1,10 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import { PropsWithChildren, ReactNode } from "react";
import type { PropsWithChildren, ReactNode } from "react";
import {
TouchableOpacity,
TouchableOpacityProps,
type TouchableOpacityProps,
View,
ViewProps,
type ViewProps,
} from "react-native";
import { Text } from "../common/Text";
@@ -86,10 +86,10 @@ const ListItemContent = ({
}: Props) => {
return (
<>
<View className="flex flex-row items-center w-full">
<View className='flex flex-row items-center w-full'>
{icon && (
<View className="border border-neutral-800 rounded-md h-8 w-8 flex items-center justify-center mr-2">
<Ionicons name="person-circle-outline" size={18} color="white" />
<View className='border border-neutral-800 rounded-md h-8 w-8 flex items-center justify-center mr-2'>
<Ionicons name='person-circle-outline' size={18} color='white' />
</View>
)}
<Text
@@ -97,24 +97,24 @@ const ListItemContent = ({
textColor === "blue"
? "text-[#0584FE]"
: textColor === "red"
? "text-red-600"
: "text-white"
? "text-red-600"
: "text-white"
}
numberOfLines={1}
>
{title}
</Text>
{value && (
<View className="ml-auto items-end">
<Text selectable className=" text-[#9899A1]" numberOfLines={1}>
<View className='ml-auto items-end'>
<Text selectable className=' text-[#9899A1]' numberOfLines={1}>
{value}
</Text>
</View>
)}
{children && <View className="ml-auto">{children}</View>}
{children && <View className='ml-auto'>{children}</View>}
{showArrow && (
<View className={children ? "ml-1" : "ml-auto"}>
<Ionicons name="chevron-forward" size={18} color="#5A5960" />
<Ionicons name='chevron-forward' size={18} color='#5A5960' />
</View>
)}
</View>

View File

@@ -10,7 +10,7 @@ export const HourHeader = ({ height }: { height: number }) => {
return (
<View
className="flex flex-row"
className='flex flex-row'
style={{
height,
}}
@@ -23,8 +23,8 @@ export const HourHeader = ({ height }: { height: number }) => {
};
const HourCell = ({ hour }: { hour: Date }) => (
<View className="w-[200px] flex items-center justify-center bg-neutral-800">
<Text className="text-xs text-gray-600">
<View className='w-[200px] flex items-center justify-center bg-neutral-800'>
<Text className='text-xs text-gray-600'>
{hour.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",

View File

@@ -1,4 +1,4 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useMemo, useRef } from "react";
import { Dimensions, View } from "react-native";
import { Text } from "../common/Text";
@@ -53,7 +53,7 @@ export const LiveTVGuideRow = ({
}
return (
<View key={channel.ChannelNumber} className="flex flex-row h-16">
<View key={channel.ChannelNumber} className='flex flex-row h-16'>
{programsWithPositions?.map((p) => (
<TouchableItemRouter item={p} key={p.Id}>
<View
@@ -66,7 +66,7 @@ export const LiveTVGuideRow = ({
? "rgba(255, 255, 255, 0.1)"
: "transparent",
}}
className="flex flex-col items-center justify-center border border-neutral-800 overflow-hidden"
className='flex flex-col items-center justify-center border border-neutral-800 overflow-hidden'
>
{(() => {
return (
@@ -77,11 +77,11 @@ export const LiveTVGuideRow = ({
? scrollX - p.position
: 0,
}}
className="px-4 self-start"
className='px-4 self-start'
>
<Text
numberOfLines={2}
className="text-xs text-start self-start"
className='text-xs text-start self-start'
>
{p.Name}
</Text>

View File

@@ -1,22 +1,22 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
import type {
BaseItemDto,
BaseItemDtoQueryResult,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import {
type QueryFunction,
type QueryKey,
useQuery,
} from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useCallback } from "react";
import { View, ViewProps } from "react-native";
import { View, type ViewProps } from "react-native";
import { ItemCardText } from "../ItemCardText";
import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
import { Text } from "../common/Text";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText";
import MoviePoster from "../posters/MoviePoster";
import {
type QueryKey,
type QueryFunction,
useQuery,
} from "@tanstack/react-query";
interface Props extends ViewProps {
queryKey: QueryKey;
@@ -54,14 +54,14 @@ export const MediaListSection: React.FC<Props> = ({
return response.data;
},
[api, user?.Id, collection?.Id]
[api, user?.Id, collection?.Id],
);
if (!collection) return null;
return (
<View {...props}>
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
{collection.Name}
</Text>
<InfiniteHorizontalScroll

View File

@@ -1,6 +1,6 @@
import { Text } from "@/components/common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View, ViewProps } from "react-native";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {
item: BaseItemDto;
@@ -9,10 +9,10 @@ interface Props extends ViewProps {
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text uiTextView selectable className="font-bold text-2xl mb-1">
<Text uiTextView selectable className='font-bold text-2xl mb-1'>
{item?.Name}
</Text>
<Text className="opacity-50">{item?.ProductionYear}</Text>
<Text className='opacity-50'>{item?.ProductionYear}</Text>
</View>
);
};

View File

@@ -1,8 +1,8 @@
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
import Ionicons from "@expo/vector-icons/Ionicons";
import { type IconProps } from "@expo/vector-icons/build/createIconSet";
import { type ComponentProps } from "react";
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
import type { ComponentProps } from "react";
export function TabBarIcon({
style,

View File

@@ -1,11 +1,11 @@
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
type MoviePosterProps = {
item: BaseItemDto;
@@ -25,7 +25,7 @@ export const EpisodePoster: React.FC<MoviePosterProps> = ({
}, [item]);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
item.UserData?.PlayedPercentage || 0,
);
const blurhash = useMemo(() => {
@@ -34,7 +34,7 @@ export const EpisodePoster: React.FC<MoviePosterProps> = ({
}, [item]);
return (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
<Image
placeholder={{
blurhash,
@@ -49,7 +49,7 @@ export const EpisodePoster: React.FC<MoviePosterProps> = ({
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
contentFit='cover'
style={{
aspectRatio: "10/15",
width: "100%",
@@ -57,7 +57,7 @@ export const EpisodePoster: React.FC<MoviePosterProps> = ({
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className="h-1 bg-red-600 w-full"></View>
<View className='h-1 bg-red-600 w-full'></View>
)}
</View>
);

View File

@@ -1,12 +1,12 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import {
BaseItemDto,
type BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { ItemImage } from "../common/ItemImage";
import { WatchedIndicator } from "../WatchedIndicator";
import { useState } from "react";
import { View, type ViewProps } from "react-native";
import { WatchedIndicator } from "../WatchedIndicator";
import { ItemImage } from "../common/ItemImage";
interface Props extends ViewProps {
item: BaseItemDto;
@@ -19,13 +19,13 @@ export const ItemPoster: React.FC<Props> = ({
...props
}) => {
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
item.UserData?.PlayedPercentage || 0,
);
if (item.Type === "Movie" || item.Type === "Series" || item.Type === "BoxSet")
return (
<View
className="relative rounded-lg overflow-hidden border border-neutral-900"
className='relative rounded-lg overflow-hidden border border-neutral-900'
{...props}
>
<ItemImage
@@ -37,17 +37,17 @@ export const ItemPoster: React.FC<Props> = ({
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className="h-1 bg-red-600 w-full"></View>
<View className='h-1 bg-red-600 w-full'></View>
)}
</View>
);
return (
<View
className="rounded-lg w-full aspect-square overflow-hidden border border-neutral-900"
className='rounded-lg w-full aspect-square overflow-hidden border border-neutral-900'
{...props}
>
<ItemImage className="w-full aspect-square" item={item} />
<ItemImage className='w-full aspect-square' item={item} />
</View>
);
};

View File

@@ -1,23 +1,30 @@
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
import {Text} from "@/components/common/Text";
import { Tag, Tags } from "@/components/GenreTags";
import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
import { Text } from "@/components/common/Text";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {useJellyseerrCanRequest} from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {Image} from "expo-image";
import {useMemo} from "react";
import {View, ViewProps} from "react-native";
import Animated, {useAnimatedStyle, useSharedValue, withTiming,} from "react-native-reanimated";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import type {DownloadingItem} from "@/utils/jellyseerr/server/lib/downloadtracker";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import {useTranslation} from "react-i18next";
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
import {Colors} from "@/constants/Colors";
import {Tag, Tags} from "@/components/GenreTags";
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import { Colors } from "@/constants/Colors";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Image } from "expo-image";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
interface Props extends ViewProps {
item?: MovieResult | TvResult | MovieDetails | TvDetails;
@@ -36,7 +43,7 @@ const JellyseerrPoster: React.FC<Props> = ({
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
const loadingOpacity = useSharedValue(1);
const imageOpacity = useSharedValue(0);
const {t} = useTranslation();
const { t } = useTranslation();
const imageAnimatedStyle = useAnimatedStyle(() => ({
opacity: imageOpacity.value,
@@ -48,65 +55,70 @@ const JellyseerrPoster: React.FC<Props> = ({
};
const backdropSrc = useMemo(
() => jellyseerrApi?.imageProxy(item?.backdropPath, "w1920_and_h800_multi_faces"),
[item, jellyseerrApi, horizontal]
() =>
jellyseerrApi?.imageProxy(
item?.backdropPath,
"w1920_and_h800_multi_faces",
),
[item, jellyseerrApi, horizontal],
);
const posterSrc = useMemo(
() => jellyseerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face",),
[item, jellyseerrApi, horizontal]
() => jellyseerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"),
[item, jellyseerrApi, horizontal],
);
const title = useMemo(() => getTitle(item), [item]);
const releaseYear = useMemo(() => getYear(item), [item]);
const mediaType = useMemo(() => getMediaType(item), [item]);
const size = useMemo(() => horizontal ? 'h-28' : 'w-28', [horizontal])
const ratio = useMemo(() => horizontal ? '15/10' : '10/15', [horizontal])
const size = useMemo(() => (horizontal ? "h-28" : "w-28"), [horizontal]);
const ratio = useMemo(() => (horizontal ? "15/10" : "10/15"), [horizontal]);
const [canRequest] = useJellyseerrCanRequest(item);
const is4k = useMemo(
() => mediaRequest?.is4k === true,
[mediaRequest]
);
const is4k = useMemo(() => mediaRequest?.is4k === true, [mediaRequest]);
const downloadItems = useMemo(
() => (is4k ? mediaRequest?.media.downloadStatus4k : mediaRequest?.media.downloadStatus) || [],
[mediaRequest, is4k]
)
() =>
(is4k
? mediaRequest?.media.downloadStatus4k
: mediaRequest?.media.downloadStatus) || [],
[mediaRequest, is4k],
);
const progress = useMemo(() => {
const [totalSize, sizeLeft] = downloadItems
.reduce((sum: number[], next: DownloadingItem) =>
[sum[0] + next.size, sum[1] + next.sizeLeft],
[0, 0]
);
const [totalSize, sizeLeft] = downloadItems.reduce(
(sum: number[], next: DownloadingItem) => [
sum[0] + next.size,
sum[1] + next.sizeLeft,
],
[0, 0],
);
return (((totalSize - sizeLeft) / totalSize) * 100);
},
[downloadItems]
);
return ((totalSize - sizeLeft) / totalSize) * 100;
}, [downloadItems]);
const requestedSeasons: string[] | undefined = useMemo(
() => {
const seasons = mediaRequest?.seasons?.flatMap(s => s.seasonNumber.toString()) || []
if (seasons.length > 4) {
const [first, second, third, fourth, ...rest] = seasons;
return [first, second, third, fourth, t("home.settings.plugins.jellyseerr.plus_n_more", {n: rest.length })]
}
return seasons
},
[mediaRequest]
);
const requestedSeasons: string[] | undefined = useMemo(() => {
const seasons =
mediaRequest?.seasons?.flatMap((s) => s.seasonNumber.toString()) || [];
if (seasons.length > 4) {
const [first, second, third, fourth, ...rest] = seasons;
return [
first,
second,
third,
fourth,
t("home.settings.plugins.jellyseerr.plus_n_more", { n: rest.length }),
];
}
return seasons;
}, [mediaRequest]);
const available = useMemo(
() => {
const status = mediaRequest?.media?.[is4k ? 'status4k' : 'status'];
return status === MediaStatus.AVAILABLE
},
[mediaRequest, is4k]
);
const available = useMemo(() => {
const status = mediaRequest?.media?.[is4k ? "status4k" : "status"];
return status === MediaStatus.AVAILABLE;
}, [mediaRequest, is4k]);
return (
<TouchableJellyseerrRouter
@@ -114,57 +126,58 @@ const JellyseerrPoster: React.FC<Props> = ({
mediaTitle={title}
releaseYear={releaseYear}
canRequest={canRequest}
posterSrc={posterSrc!!}
posterSrc={posterSrc!}
mediaType={mediaType}
>
<View className={`flex flex-col mr-2 h-auto`}>
<View className={`relative rounded-lg overflow-hidden border border-neutral-900 ${size} aspect-[${ratio}]`}>
<View
className={`relative rounded-lg overflow-hidden border border-neutral-900 ${size} aspect-[${ratio}]`}
>
<Animated.View style={imageAnimatedStyle}>
<Image
className="w-full"
className='w-full'
key={item?.id}
id={item?.id.toString()}
source={{ uri: horizontal ? backdropSrc : posterSrc }}
cachePolicy={"memory-disk"}
contentFit="cover"
contentFit='cover'
style={{
aspectRatio: ratio,
[horizontal ? 'height' : 'width']: "100%"
[horizontal ? "height" : "width"]: "100%",
}}
onLoad={handleImageLoad}
/>
</Animated.View>
{mediaRequest && showDownloadInfo && (
<>
<View className={`absolute w-full h-full bg-black ${!available ? 'opacity-70' : 'opacity-0'}`} />
<View
className={`absolute w-full h-full bg-black ${!available ? "opacity-70" : "opacity-0"}`}
/>
{!available && !Number.isNaN(progress) && (
<>
<View
className="absolute left-0 h-full opacity-40"
className='absolute left-0 h-full opacity-40'
style={{
width: `${progress || 0}%`,
backgroundColor: Colors.primaryRGB,
}}
/>
<View className="absolute w-full h-full justify-center items-center">
<Text
className="font-bold"
style={textShadowStyle.shadow}
>
<View className='absolute w-full h-full justify-center items-center'>
<Text className='font-bold' style={textShadowStyle.shadow}>
{progress?.toFixed(0)}%
</Text>
</View>
</>
)}
<Tag
className="absolute right-1 top-1 text-right bg-black border border-neutral-800/50"
className='absolute right-1 top-1 text-right bg-black border border-neutral-800/50'
text={mediaRequest?.requestedBy.displayName}
/>
{requestedSeasons.length > 0 && (
<Tags
className="absolute bottom-1 left-0.5 w-32"
className='absolute bottom-1 left-0.5 w-32'
tagProps={{
className: "bg-black rounded-full px-1"
className: "bg-black rounded-full px-1",
}}
tags={requestedSeasons}
/>
@@ -172,19 +185,21 @@ const JellyseerrPoster: React.FC<Props> = ({
</>
)}
<JellyseerrStatusIcon
className="absolute bottom-1 right-1"
className='absolute bottom-1 right-1'
showRequestIcon={canRequest}
mediaStatus={mediaRequest?.media?.status || item?.mediaInfo?.status}
/>
<JellyseerrMediaIcon
className="absolute top-1 left-1"
className='absolute top-1 left-1'
mediaType={mediaType}
/>
</View>
</View>
<View className={`mt-2 flex flex-col ${horizontal ? 'w-44' : 'w-28'}`}>
<View className={`mt-2 flex flex-col ${horizontal ? "w-44" : "w-28"}`}>
<Text numberOfLines={2}>{title || ""}</Text>
<Text className="text-xs opacity-50 align-bottom">{releaseYear || ""}</Text>
<Text className='text-xs opacity-50 align-bottom'>
{releaseYear || ""}
</Text>
</View>
</TouchableJellyseerrRouter>
);

View File

@@ -1,7 +1,7 @@
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
@@ -27,7 +27,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
}, [item]);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
item.UserData?.PlayedPercentage || 0,
);
const blurhash = useMemo(() => {
@@ -36,7 +36,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
}, [item]);
return (
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
<View className='relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]'>
<Image
placeholder={{
blurhash,
@@ -51,7 +51,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
contentFit='cover'
style={{
aspectRatio: "10/15",
width: "100%",
@@ -59,7 +59,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className="h-1 bg-red-600 w-full"></View>
<View className='h-1 bg-red-600 w-full'></View>
)}
</View>
);

View File

@@ -14,13 +14,13 @@ const ParentPoster: React.FC<PosterProps> = ({ id }) => {
const url = useMemo(
() => `${api?.basePath}/Items/${id}/Images/Primary`,
[id]
[id],
);
if (!url || !id)
return (
<View
className="border border-neutral-900"
className='border border-neutral-900'
style={{
aspectRatio: "10/15",
}}
@@ -28,7 +28,7 @@ const ParentPoster: React.FC<PosterProps> = ({ id }) => {
);
return (
<View className="rounded-lg overflow-hidden border border-neutral-900">
<View className='rounded-lg overflow-hidden border border-neutral-900'>
<Image
key={id}
id={id}
@@ -36,7 +36,7 @@ const ParentPoster: React.FC<PosterProps> = ({ id }) => {
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
contentFit='cover'
style={{
aspectRatio: "10/15",
}}

View File

@@ -12,7 +12,7 @@ const Poster: React.FC<PosterProps> = ({ id, url, blurhash }) => {
if (!id && !url)
return (
<View
className="border border-neutral-900"
className='border border-neutral-900'
style={{
aspectRatio: "10/15",
}}
@@ -20,7 +20,7 @@ const Poster: React.FC<PosterProps> = ({ id, url, blurhash }) => {
);
return (
<View className="rounded-lg overflow-hidden border border-neutral-900">
<View className='rounded-lg overflow-hidden border border-neutral-900'>
<Image
placeholder={
blurhash
@@ -30,7 +30,7 @@ const Poster: React.FC<PosterProps> = ({ id, url, blurhash }) => {
: null
}
key={id}
id={id!!}
id={id!}
source={
url
? {
@@ -39,7 +39,7 @@ const Poster: React.FC<PosterProps> = ({ id, url, blurhash }) => {
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
contentFit='cover'
style={{
aspectRatio: "10/15",
}}

View File

@@ -1,11 +1,11 @@
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
type MoviePosterProps = {
item: BaseItemDto;
@@ -32,7 +32,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
}, [item]);
return (
<View className="w-28 aspect-[10/15] relative rounded-lg overflow-hidden border border-neutral-900 ">
<View className='w-28 aspect-[10/15] relative rounded-lg overflow-hidden border border-neutral-900 '>
<Image
placeholder={{
blurhash,
@@ -47,7 +47,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
contentFit='cover'
style={{
height: "100%",
width: "100%",

View File

@@ -1,11 +1,11 @@
import { View } from "react-native";
import { Text } from "../common/Text";
import Animated, {
useAnimatedStyle,
useAnimatedReaction,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Text } from "../common/Text";
interface Props {
isLoading: boolean;
@@ -28,29 +28,29 @@ export const LoadingSkeleton: React.FC<Props> = ({ isLoading }) => {
} else {
opacity.value = withTiming(0, { duration: 200 });
}
}
},
);
return (
<Animated.View style={animatedStyle} className="mt-2 absolute w-full">
<Animated.View style={animatedStyle} className='mt-2 absolute w-full'>
{[1, 2, 3].map((s) => (
<View className="px-4 mb-4" key={s}>
<View className="w-1/2 bg-neutral-900 h-6 mb-2 rounded-lg"></View>
<View className="flex flex-row gap-2">
<View className='px-4 mb-4' key={s}>
<View className='w-1/2 bg-neutral-900 h-6 mb-2 rounded-lg'></View>
<View className='flex flex-row gap-2'>
{[1, 2, 3].map((i) => (
<View className="w-28" key={i}>
<View className="bg-neutral-900 h-40 w-full rounded-md mb-1"></View>
<View className="rounded-md overflow-hidden mb-1 self-start">
<View className='w-28' key={i}>
<View className='bg-neutral-900 h-40 w-full rounded-md mb-1'></View>
<View className='rounded-md overflow-hidden mb-1 self-start'>
<Text
className="text-neutral-900 bg-neutral-900 rounded-md"
className='text-neutral-900 bg-neutral-900 rounded-md'
numberOfLines={1}
>
Nisi mollit voluptate amet.
</Text>
</View>
<View className="rounded-md overflow-hidden self-start mb-1">
<View className='rounded-md overflow-hidden self-start mb-1'>
<Text
className="text-neutral-900 bg-neutral-900 text-xs rounded-md"
className='text-neutral-900 bg-neutral-900 text-xs rounded-md'
numberOfLines={1}
>
Lorem ipsum

View File

@@ -1,11 +1,12 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, { PropsWithChildren } from "react";
import type React from "react";
import type { PropsWithChildren } from "react";
import { Text } from "../common/Text";
import {FlashList} from "@shopify/flash-list";
type SearchItemWrapperProps<T> = {
ids?: string[] | null;
@@ -15,12 +16,12 @@ type SearchItemWrapperProps<T> = {
onEndReached?: (() => void) | null | undefined;
};
export const SearchItemWrapper = <T extends unknown>({
export const SearchItemWrapper = <T,>({
ids,
items,
renderItem,
header,
onEndReached
onEndReached,
}: PropsWithChildren<SearchItemWrapperProps<T>>) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -37,25 +38,25 @@ export const SearchItemWrapper = <T extends unknown>({
api,
userId: user.Id,
itemId: id,
})
}),
);
const results = await Promise.all(itemPromises);
// Filter out null items
return results.filter(
(item) => item !== null
(item) => item !== null,
) as unknown as BaseItemDto[];
},
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
staleTime: Infinity,
staleTime: Number.POSITIVE_INFINITY,
});
if (!data && (!items || items.length === 0)) return null;
return (
<>
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
<Text className='font-bold text-lg px-4 mb-2'>{header}</Text>
<FlashList
horizontal
contentContainerStyle={{
@@ -70,7 +71,7 @@ export const SearchItemWrapper = <T extends unknown>({
onEndReachedThreshold={1}
onEndReached={onEndReached}
//@ts-ignore
renderItem={({item, index}) => item ? renderItem(item) : <></>}
renderItem={({ item, index }) => (item ? renderItem(item) : <></>)}
/>
</>
);

View File

@@ -1,18 +1,19 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import {
import type {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { router, useSegments } from "expo-router";
import { useAtom } from "jotai";
import React, { useMemo } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import Poster from "../posters/Poster";
import { itemRouter } from "../common/TouchableItemRouter";
import { useTranslation } from "react-i18next";
import Poster from "../posters/Poster";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -41,8 +42,10 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
if (!from) return null;
return (
<View {...props} className="flex flex-col">
<Text className="text-lg font-bold mb-2 px-4">{t("item_card.cast_and_crew")}</Text>
<View {...props} className='flex flex-col'>
<Text className='text-lg font-bold mb-2 px-4'>
{t("item_card.cast_and_crew")}
</Text>
<HorizontalScroll
loading={loading}
keyExtractor={(i, idx) => i.Id.toString()}
@@ -55,11 +58,11 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
// @ts-ignore
router.push(url);
}}
className="flex flex-col w-28"
className='flex flex-col w-28'
>
<Poster id={i.id} url={getPrimaryImageUrl({ api, item: i })} />
<Text className="mt-2">{i.Name}</Text>
<Text className="text-xs opacity-50">{i.Role}</Text>
<Text className='mt-2'>{i.Name}</Text>
<Text className='text-xs opacity-50'>{i.Role}</Text>
</TouchableOpacity>
)}
/>

View File

@@ -1,14 +1,14 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { useAtom } from "jotai";
import React from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import Poster from "../posters/Poster";
import type React from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { useTranslation } from "react-i18next";
import Poster from "../posters/Poster";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -20,7 +20,9 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text className="text-lg font-bold mb-2 px-4">{t("item_card.series")}</Text>
<Text className='text-lg font-bold mb-2 px-4'>
{t("item_card.series")}
</Text>
<HorizontalScroll
data={[item]}
height={247}
@@ -28,7 +30,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.SeriesId}`)}
className="flex flex-col space-y-2 w-28"
className='flex flex-col space-y-2 w-28'
>
<Poster
id={item.id}

View File

@@ -1,7 +1,7 @@
import { Text } from "@/components/common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter } from "expo-router";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { TouchableOpacity, View, type ViewProps } from "react-native";
interface Props extends ViewProps {
item: BaseItemDto;
@@ -12,25 +12,25 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text uiTextView className="font-bold text-2xl" selectable>
<Text uiTextView className='font-bold text-2xl' selectable>
{item?.Name}
</Text>
<View className="flex flex-row items-center mb-1">
<View className='flex flex-row items-center mb-1'>
<TouchableOpacity
onPress={() => {
router.push(
// @ts-ignore
`/(auth)/series/${item.SeriesId}?seasonIndex=${item?.ParentIndexNumber}`
`/(auth)/series/${item.SeriesId}?seasonIndex=${item?.ParentIndexNumber}`,
);
}}
>
<Text className="opacity-50">{item?.SeasonName}</Text>
<Text className='opacity-50'>{item?.SeasonName}</Text>
</TouchableOpacity>
<Text className="opacity-50 mx-2">{"—"}</Text>
<Text className="opacity-50">{`Episode ${item.IndexNumber}`}</Text>
<Text className='opacity-50 mx-2'>{"—"}</Text>
<Text className='opacity-50'>{`Episode ${item.IndexNumber}`}</Text>
</View>
<Text className="opacity-50">{item?.ProductionYear}</Text>
<Text className='opacity-50'>{item?.ProductionYear}</Text>
</View>
);
};

View File

@@ -1,30 +1,35 @@
import { Text } from "@/components/common/Text";
import React, { useCallback, useMemo, useState } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { FlashList } from "@shopify/flash-list";
import { orderBy } from "lodash";
import { Tags } from "@/components/GenreTags";
import { RoundButton } from "@/components/RoundButton";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Text } from "@/components/common/Text";
import { dateOpts } from "@/components/jellyseerr/DetailFacts";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import Season from "@/utils/jellyseerr/server/entity/Season";
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import { Ionicons } from "@expo/vector-icons";
import { RoundButton } from "@/components/RoundButton";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type Season from "@/utils/jellyseerr/server/entity/Season";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
import {QueryObserverResult, RefetchOptions, useQuery} from "@tanstack/react-query";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons";
import { FlashList } from "@shopify/flash-list";
import {
type QueryObserverResult,
type RefetchOptions,
useQuery,
} from "@tanstack/react-query";
import { Image } from "expo-image";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import { Loader } from "../Loader";
import { t } from "i18next";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
import {dateOpts} from "@/components/jellyseerr/DetailFacts";
import { orderBy } from "lodash";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import { Loader } from "../Loader";
const JellyseerrSeasonEpisodes: React.FC<{
details: TvDetails;
@@ -54,26 +59,27 @@ const JellyseerrSeasonEpisodes: React.FC<{
};
const RenderItem = ({ item, index }: any) => {
const { jellyseerrApi, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const {
jellyseerrApi,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const [imageError, setImageError] = useState(false);
const upcomingAirDate = useMemo(() => {
const airDate = item.airDate;
if (airDate) {
let airDateObj = new Date(airDate);
const airDateObj = new Date(airDate);
if (new Date() < airDateObj) {
return airDateObj.toLocaleDateString(
`${locale}-${region}`,
dateOpts
);
return airDateObj.toLocaleDateString(`${locale}-${region}`, dateOpts);
}
}
}, [item]);
return (
<View className="flex flex-col w-44 mt-2">
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
<View className='flex flex-col w-44 mt-2'>
<View className='relative aspect-video rounded-lg overflow-hidden border border-neutral-800'>
{!imageError ? (
<>
<Image
@@ -83,16 +89,19 @@ const RenderItem = ({ item, index }: any) => {
uri: jellyseerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
contentFit='cover'
className='w-full h-full'
onError={(e) => {
setImageError(true);
}}
/>
{upcomingAirDate && (
<View className="absolute justify-center bottom-0 right-0.5 items-center">
<View className="rounded-full bg-purple-600/30 p-1">
<Text className="text-center text-xs" style={textShadowStyle.shadow}>
<View className='absolute justify-center bottom-0 right-0.5 items-center'>
<View className='rounded-full bg-purple-600/30 p-1'>
<Text
className='text-center text-xs'
style={textShadowStyle.shadow}
>
{upcomingAirDate}
</Text>
</View>
@@ -100,26 +109,26 @@ const RenderItem = ({ item, index }: any) => {
)}
</>
) : (
<View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900">
<View className='flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900'>
<Ionicons
name="image-outline"
name='image-outline'
size={24}
color="white"
color='white'
style={{ opacity: 0.4 }}
/>
</View>
)}
</View>
<View className="shrink mt-1">
<Text numberOfLines={2} className="">
<View className='shrink mt-1'>
<Text numberOfLines={2} className=''>
{item.name}
</Text>
<Text numberOfLines={1} className="text-xs text-neutral-500">
<Text numberOfLines={1} className='text-xs text-neutral-500'>
{`S${item.seasonNumber}:E${item.episodeNumber}`}
</Text>
</View>
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
<Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
{item.overview}
</Text>
</View>
@@ -129,9 +138,13 @@ const RenderItem = ({ item, index }: any) => {
const JellyseerrSeasons: React.FC<{
isLoading: boolean;
details?: TvDetails;
hasAdvancedRequest?: boolean,
hasAdvancedRequest?: boolean;
onAdvancedRequest?: (data: MediaRequestBody) => void;
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
refetch: (
options?: RefetchOptions | undefined,
) => Promise<
QueryObserverResult<TvDetails | MovieDetails | undefined, Error>
>;
}> = ({
isLoading,
details,
@@ -147,10 +160,10 @@ const JellyseerrSeasons: React.FC<{
}>();
const seasons = useMemo(() => {
const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter(
(s: Season) => s.seasonNumber !== 0
(s: Season) => s.seasonNumber !== 0,
);
const requestedSeasons = details?.mediaInfo?.requests?.flatMap(
(r: MediaRequest) => r.seasons
(r: MediaRequest) => r.seasons,
);
return details.seasons?.map((season) => {
return {
@@ -159,11 +172,11 @@ const JellyseerrSeasons: React.FC<{
// What our library status is
mediaInfoSeasons?.find(
(mediaSeason: Season) =>
mediaSeason.seasonNumber === season.seasonNumber
mediaSeason.seasonNumber === season.seasonNumber,
)?.status ??
// What our request status is
requestedSeasons?.find(
(s: Season) => s.seasonNumber === season.seasonNumber
(s: Season) => s.seasonNumber === season.seasonNumber,
)?.status ??
// Otherwise set it as unknown
MediaStatus.UNKNOWN,
@@ -173,7 +186,7 @@ const JellyseerrSeasons: React.FC<{
const allSeasonsAvailable = useMemo(
() => seasons?.every((season) => season.status === MediaStatus.AVAILABLE),
[seasons]
[seasons],
);
const requestAll = useCallback(() => {
@@ -184,13 +197,13 @@ const JellyseerrSeasons: React.FC<{
tvdbId: details.externalIds?.tvdbId,
seasons: seasons
.filter(
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0,
)
.map((s) => s.seasonNumber),
}
};
if (hasAdvancedRequest) {
return onAdvancedRequest?.(body)
return onAdvancedRequest?.(body);
}
requestMedia(details.name, body, refetch);
@@ -199,44 +212,53 @@ const JellyseerrSeasons: React.FC<{
const promptRequestAll = useCallback(
() =>
Alert.alert(t("jellyseerr.confirm"), t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"), [
{
text: t("jellyseerr.cancel"),
style: "cancel",
},
{
text: t("jellyseerr.yes"),
onPress: requestAll,
},
]),
[requestAll]
Alert.alert(
t("jellyseerr.confirm"),
t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"),
[
{
text: t("jellyseerr.cancel"),
style: "cancel",
},
{
text: t("jellyseerr.yes"),
onPress: requestAll,
},
],
),
[requestAll],
);
const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => {
if (canRequest) {
const body: MediaRequestBody = {
mediaId: details.id,
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
seasons: [seasonNumber],
}
const requestSeason = useCallback(
async (canRequest: boolean, seasonNumber: number) => {
if (canRequest) {
const body: MediaRequestBody = {
mediaId: details.id,
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
seasons: [seasonNumber],
};
if (hasAdvancedRequest) {
return onAdvancedRequest?.(body)
}
if (hasAdvancedRequest) {
return onAdvancedRequest?.(body);
}
requestMedia(`${details.name}, Season ${seasonNumber}`, body, refetch);
}
}, [requestMedia, hasAdvancedRequest, onAdvancedRequest]);
requestMedia(`${details.name}, Season ${seasonNumber}`, body, refetch);
}
},
[requestMedia, hasAdvancedRequest, onAdvancedRequest],
);
if (isLoading)
return (
<View>
<View className="flex flex-row justify-between items-end px-4">
<Text className="text-lg font-bold mb-2">{t("item_card.seasons")}</Text>
<View className='flex flex-row justify-between items-end px-4'>
<Text className='text-lg font-bold mb-2'>
{t("item_card.seasons")}
</Text>
{!allSeasonsAvailable && (
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
<Ionicons name="bag-add" color="white" size={26} />
<RoundButton className='mb-2 pa-2' onPress={promptRequestAll}>
<Ionicons name='bag-add' color='white' size={26} />
</RoundButton>
)}
</View>
@@ -249,19 +271,21 @@ const JellyseerrSeasons: React.FC<{
data={orderBy(
details.seasons.filter((s) => s.seasonNumber !== 0),
"seasonNumber",
"desc"
"desc",
)}
ListHeaderComponent={() => (
<View className="flex flex-row justify-between items-end px-4">
<Text className="text-lg font-bold mb-2">{t("item_card.seasons")}</Text>
<View className='flex flex-row justify-between items-end px-4'>
<Text className='text-lg font-bold mb-2'>
{t("item_card.seasons")}
</Text>
{!allSeasonsAvailable && (
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
<Ionicons name="bag-add" color="white" size={26} />
<RoundButton className='mb-2 pa-2' onPress={promptRequestAll}>
<Ionicons name='bag-add' color='white' size={26} />
</RoundButton>
)}
</View>
)}
ItemSeparatorComponent={() => <View className="h-2" />}
ItemSeparatorComponent={() => <View className='h-2' />}
estimatedItemSize={250}
renderItem={({ item: season }) => (
<>
@@ -272,17 +296,21 @@ const JellyseerrSeasons: React.FC<{
[season.seasonNumber]: !prevState?.[season.seasonNumber],
}))
}
className="px-4"
className='px-4'
>
<View
className="flex flex-row justify-between items-center bg-gray-100/10 rounded-xl z-20 h-12 w-full px-4"
className='flex flex-row justify-between items-center bg-gray-100/10 rounded-xl z-20 h-12 w-full px-4'
key={season.id}
>
<Tags
textClass=""
textClass=''
tags={[
t("jellyseerr.season_number", {season_number: season.seasonNumber}),
t("jellyseerr.number_episodes", {episode_number: season.episodeCount}),
t("jellyseerr.season_number", {
season_number: season.seasonNumber,
}),
t("jellyseerr.number_episodes", {
episode_number: season.episodeCount,
}),
]}
/>
{[0].map(() => {
@@ -292,11 +320,13 @@ const JellyseerrSeasons: React.FC<{
return (
<JellyseerrStatusIcon
key={0}
onPress={() => requestSeason(canRequest, season.seasonNumber)}
onPress={() =>
requestSeason(canRequest, season.seasonNumber)
}
className={canRequest ? "bg-gray-700/40" : undefined}
mediaStatus={
seasons?.find(
(s) => s.seasonNumber === season.seasonNumber
(s) => s.seasonNumber === season.seasonNumber,
)?.status
}
showRequestIcon={canRequest}

View File

@@ -1,12 +1,12 @@
import { Ionicons } from "@expo/vector-icons";
import { Button } from "../Button";
import { useRouter } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { Button } from "../Button";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
@@ -62,9 +62,9 @@ export const NextItemButton: React.FC<Props> = ({
{...props}
>
{type === "next" ? (
<Ionicons name="chevron-forward" size={24} color="white" />
<Ionicons name='chevron-forward' size={24} color='white' />
) : (
<Ionicons name="chevron-back" size={24} color="white" />
<Ionicons name='chevron-back' size={24} color='white' />
)}
</Button>
);

View File

@@ -1,18 +1,18 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import React from "react";
import type React from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { FlashList } from "@shopify/flash-list";
import { useTranslation } from "react-i18next";
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
const [user] = useAtom(userAtom);
@@ -38,15 +38,17 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
if (!items?.length)
return (
<View className="px-4">
<Text className="text-lg font-bold mb-2">{t("item_card.next_up")}</Text>
<Text className="opacity-50">{t("item_card.no_items_to_display")}</Text>
<View className='px-4'>
<Text className='text-lg font-bold mb-2'>{t("item_card.next_up")}</Text>
<Text className='opacity-50'>{t("item_card.no_items_to_display")}</Text>
</View>
);
return (
<View>
<Text className="text-lg font-bold px-4 mb-2">{t("item_card.next_up")}</Text>
<Text className='text-lg font-bold px-4 mb-2'>
{t("item_card.next_up")}
</Text>
<FlashList
contentContainerStyle={{ paddingLeft: 16 }}
horizontal
@@ -57,7 +59,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
<TouchableItemRouter
item={item}
key={index}
className="flex flex-col w-44"
className='flex flex-col w-44'
>
<ContinueWatchingPoster item={item} useEpisodePoster />
<ItemCardText item={item} />

View File

@@ -1,9 +1,9 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "../common/Text";
import { t } from "i18next";
import { Text } from "../common/Text";
type Props = {
item: BaseItemDto;
@@ -45,12 +45,12 @@ export const SeasonDropdown: React.FC<Props> = ({
title: "Name",
index: "IndexNumber",
},
[item]
[item],
);
const seasonIndex = useMemo(
() => state[(item[keys.id] as string) ?? ""],
[state]
[state],
);
useEffect(() => {
@@ -60,7 +60,7 @@ export const SeasonDropdown: React.FC<Props> = ({
if (initialSeasonIndex !== undefined) {
// Use the provided initialSeasonIndex if it exists in the seasons
const seasonExists = seasons.some(
(season: any) => season[keys.index] === initialSeasonIndex
(season: any) => season[keys.index] === initialSeasonIndex,
);
if (seasonExists) {
initialIndex = initialSeasonIndex;
@@ -77,7 +77,7 @@ export const SeasonDropdown: React.FC<Props> = ({
if (initialIndex !== undefined) {
const initialSeason = seasons.find(
(season: any) => season[keys.index] === initialIndex
(season: any) => season[keys.index] === initialIndex,
);
if (initialSeason) onSelect(initialSeason!);
@@ -92,8 +92,8 @@ export const SeasonDropdown: React.FC<Props> = ({
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<View className='flex flex-row'>
<TouchableOpacity className='bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
<Text>
{t("item_card.season")} {seasonIndex}
</Text>
@@ -102,8 +102,8 @@ export const SeasonDropdown: React.FC<Props> = ({
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}

View File

@@ -1,17 +1,17 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type { 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 { TouchableOpacity, View, type ViewProps } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import {
HorizontalScroll,
type HorizontalScrollRef,
} from "../common/HorrizontalScroll";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -56,7 +56,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
},
);
return response.data.Items as BaseItemDto[];
@@ -74,7 +74,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}
const previousId = episodes?.find(
(ep) => ep.IndexNumber === item.IndexNumber! - 1
(ep) => ep.IndexNumber === item.IndexNumber! - 1,
)?.Id;
if (previousId) {
queryClient.prefetchQuery({
@@ -90,7 +90,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}
const nextId = episodes?.find(
(ep) => ep.IndexNumber === item.IndexNumber! + 1
(ep) => ep.IndexNumber === item.IndexNumber! + 1,
)?.Id;
if (nextId) {
queryClient.prefetchQuery({

View File

@@ -1,24 +1,24 @@
import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { runtimeTicksToSeconds } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { DownloadItems, DownloadSingleItem } from "../DownloadItem";
import { Loader } from "../Loader";
import { Text } from "../common/Text";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import {
SeasonDropdown,
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PlayedStatus } from "../PlayedStatus";
import { useTranslation } from "react-i18next";
import { Text } from "../common/Text";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
type Props = {
item: BaseItemDto;
@@ -35,7 +35,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
const seasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""],
[item, seasonIndexState]
[item, seasonIndexState],
);
const { data: seasons } = useQuery({
@@ -54,7 +54,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
},
);
return response.data.Items;
@@ -66,7 +66,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
const selectedSeasonId: string | null = useMemo(() => {
const season: BaseItemDto = seasons?.find(
(s: BaseItemDto) =>
s.IndexNumber === seasonIndex || s.Name === seasonIndex
s.IndexNumber === seasonIndex || s.Name === seasonIndex,
);
if (!season?.Id) return null;
@@ -92,7 +92,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
if (res.data.TotalRecordCount === 0)
console.warn(
"No episodes found for season with ID ~",
selectedSeasonId
selectedSeasonId,
);
return res.data.Items;
@@ -102,7 +102,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
const queryClient = useQueryClient();
useEffect(() => {
for (let e of episodes || []) {
for (const e of episodes || []) {
queryClient.prefetchQuery({
queryKey: ["item", e.Id],
queryFn: async () => {
@@ -133,7 +133,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
minHeight: 144 * nrOfEpisodes,
}}
>
<View className="flex flex-row justify-start items-center px-4">
<View className='flex flex-row justify-start items-center px-4'>
<SeasonDropdown
item={item}
seasons={seasons}
@@ -147,29 +147,29 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
}}
/>
{episodes?.length || 0 > 0 ? (
<View className="flex flex-row items-center space-x-2">
<View className='flex flex-row items-center space-x-2'>
<DownloadItems
title={t("item_card.download.download_season")}
className="ml-2"
className='ml-2'
items={episodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name="download" size={20} color="white" />
<Ionicons name='download' size={20} color='white' />
)}
DownloadedIconComponent={() => (
<Ionicons name="download" size={20} color="#9333ea" />
<Ionicons name='download' size={20} color='#9333ea' />
)}
/>
<PlayedStatus items={episodes || []} />
</View>
) : null}
</View>
<View className="px-4 flex flex-col mt-4">
<View className='px-4 flex flex-col mt-4'>
{isFetching ? (
<View
style={{
minHeight: 144 * nrOfEpisodes,
}}
className="flex flex-col items-center justify-center"
className='flex flex-col items-center justify-center'
>
<Loader />
</View>
@@ -178,35 +178,35 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
<TouchableItemRouter
item={e}
key={e.Id}
className="flex flex-col mb-4"
className='flex flex-col mb-4'
>
<View className="flex flex-row items-start mb-2">
<View className="mr-2">
<View className='flex flex-row items-start mb-2'>
<View className='mr-2'>
<ContinueWatchingPoster
size="small"
size='small'
item={e}
useEpisodePoster
/>
</View>
<View className="shrink">
<Text numberOfLines={2} className="">
<View className='shrink'>
<Text numberOfLines={2} className=''>
{e.Name}
</Text>
<Text numberOfLines={1} className="text-xs text-neutral-500">
<Text numberOfLines={1} className='text-xs text-neutral-500'>
{`S${e.ParentIndexNumber?.toString()}:E${e.IndexNumber?.toString()}`}
</Text>
<Text className="text-xs text-neutral-500">
<Text className='text-xs text-neutral-500'>
{runtimeTicksToSeconds(e.RunTimeTicks)}
</Text>
</View>
<View className="self-start ml-auto -mt-0.5">
<View className='self-start ml-auto -mt-0.5'>
<DownloadSingleItem item={e} />
</View>
</View>
<Text
numberOfLines={3}
className="text-xs text-neutral-500 shrink"
className='text-xs text-neutral-500 shrink'
>
{e.Overview}
</Text>
@@ -214,8 +214,8 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
))
)}
{(episodes?.length || 0) === 0 ? (
<View className="flex flex-col">
<Text className="text-neutral-500">
<View className='flex flex-col'>
<Text className='text-neutral-500'>
{t("item_card.no_episodes_for_this_season")}
</Text>
</View>

View File

@@ -1,14 +1,14 @@
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback, useMemo } from "react";
import {
Alert,
Linking,
TouchableOpacity,
View,
ViewProps,
type ViewProps,
} from "react-native";
interface Props extends ViewProps {
@@ -42,10 +42,10 @@ export const ItemActions = ({ item, ...props }: Props) => {
}, [trailerLink]);
return (
<View className="" {...props}>
<View className='' {...props}>
{trailerLink && (
<TouchableOpacity onPress={openTrailer}>
<Ionicons name="film-outline" size={24} color="white" />
<Ionicons name='film-outline' size={24} color='white' />
</TouchableOpacity>
)}
</View>

View File

@@ -1,8 +1,8 @@
import { View } from "react-native";
import { Text } from "../common/Text";
import { Ratings } from "../Ratings";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
import { View } from "react-native";
import { Ratings } from "../Ratings";
import { Text } from "../common/Text";
import { ItemActions } from "./SeriesActions";
interface Props {
@@ -51,14 +51,14 @@ export const SeriesHeader = ({ item }: Props) => {
}, [startYear, endYear]);
return (
<View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{yearString}</Text>
<View className="flex flex-row items-center justify-between">
<Ratings item={item} className="mb-2" />
<View className='px-4 py-4'>
<Text className='text-3xl font-bold'>{item?.Name}</Text>
<Text className=''>{yearString}</Text>
<View className='flex flex-row items-center justify-between'>
<Ratings item={item} className='mb-2' />
<ItemActions item={item} />
</View>
<Text className="">{item?.Overview}</Text>
<Text className=''>{item?.Overview}</Text>
</View>
);
};

Some files were not shown because too many files have changed in this diff Show More