mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
chore: Apply linting rules and add git hok (#611)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ActivityIndicatorProps,
|
||||
type ActivityIndicatorProps,
|
||||
Platform,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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={`
|
||||
`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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]} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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%",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) : <></>)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user