Merge branch 'develop' into chore/expo-52

This commit is contained in:
Fredrik Burmester
2025-01-23 11:30:02 +01:00
176 changed files with 6125 additions and 3721 deletions

View File

@@ -3,6 +3,7 @@ import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
@@ -26,6 +27,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
[audioStreams, selected]
);
const { t } = useTranslation();
return (
<View
className="flex shrink"
@@ -36,7 +39,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
<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}>
{selectedAudioSteam?.DisplayTitle}

View File

@@ -2,6 +2,7 @@ import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export type Bitrate = {
key: string;
@@ -27,6 +28,10 @@ export const BITRATES: Bitrate[] = [
key: "2 Mb/s",
value: 2000000,
},
{
key: "1 Mb/s",
value: 1000000,
},
{
key: "500 Kb/s",
value: 500000,
@@ -59,6 +64,8 @@ export const BitrateSelector: React.FC<Props> = ({
);
}, []);
const { t } = useTranslation();
return (
<View
className="flex shrink"
@@ -70,7 +77,7 @@ export const BitrateSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
<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}>
{BITRATES.find((b) => b.value === selected?.value)?.key}

View File

@@ -1,4 +1,4 @@
import * as Haptics from "expo-haptics";
import { useHaptic } from "@/hooks/useHaptic";
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader";
@@ -37,12 +37,14 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
case "red":
return "bg-red-600";
case "black":
return "bg-neutral-900 border border-neutral-800";
return "bg-neutral-900";
case "transparent":
return "bg-transparent";
}
}, [color]);
const lightHapticFeedback = useHaptic("light");
return (
<TouchableOpacity
className={`
@@ -54,14 +56,16 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
onPress={() => {
if (!loading && !disabled && onPress) {
onPress();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
}
}}
disabled={disabled || loading}
{...props}
>
{loading ? (
<Loader />
<View className="p-0.5">
<Loader />
</View>
) : (
<View
className={`

View File

@@ -49,6 +49,11 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
if (item.ImageTags?.["Thumb"])
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}, [item]);
const progress = useMemo(() => {

View File

@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
@@ -32,6 +32,7 @@ import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { t } from "i18next";
interface DownloadProps extends ViewProps {
items: BaseItemDto[];
@@ -55,6 +56,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [user] = useAtom(userAtom);
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
const { startRemuxing } = useRemuxHlsToMp4();
@@ -74,7 +76,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[user]
);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === "optimized",
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings]
);
@@ -160,7 +162,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
);
}
} else {
toast.error("You are not allowed to download files.");
toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files"));
}
}, [
queue,
@@ -212,8 +214,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!res) {
Alert.alert(
"Something went wrong",
"Could not get stream url from Jellyfin"
t("home.downloads.something_went_wrong"),
t("home.downloads.could_not_get_stream_url_from_jellyfin")
);
continue;
}
@@ -330,7 +332,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
{title}
</Text>
<Text className="text-neutral-300">
{subtitle || `Download ${itemsNotDownloaded.length} items`}
{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">
@@ -368,13 +370,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
onPress={acceptDownloadOptions}
color="purple"
>
Download
{t("item_card.download.download_button")}
</Button>
<View className="opacity-70 text-center w-full flex items-center">
<Text className="text-xs">
{usingOptimizedServer
? "Using optimized server"
: "Using default method"}
? t("item_card.download.using_optimized_server")
: t("item_card.download.using_default_method")}
</Text>
</View>
</View>
@@ -391,7 +393,9 @@ export const DownloadSingleItem: React.FC<{
return (
<DownloadItems
size={size}
title="Download Episode"
title={item.Type == "Episode"
? t("item_card.download.download_episode")
: t("item_card.download.download_movie")}
subtitle={item.Name!}
items={[item]}
MissingDownloadIconComponent={() => (

View File

@@ -1,6 +1,6 @@
// GenreTags.tsx
import React from "react";
import {View, ViewProps} from "react-native";
import {StyleProp, TextStyle, View, ViewProps} from "react-native";
import { Text } from "./common/Text";
interface TagProps {
@@ -8,14 +8,15 @@ interface TagProps {
textClass?: ViewProps["className"]
}
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
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}>{text}</Text>
<Text className={textClass} style={textStyle}>{text}</Text>
</View>
);
};

View File

@@ -15,6 +15,7 @@ import {
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { Button } from "./Button";
import { useTranslation } from "react-i18next";
interface Props {
source?: MediaSourceInfo;
@@ -22,15 +23,16 @@ interface Props {
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
return (
<View className="px-4 mt-2 mb-4">
<Text className="text-lg font-bold mb-4">Video</Text>
<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">
<VideoStreamInfo source={source} />
</View>
<Text className="text-purple-600">More details</Text>
<Text className="text-purple-600">{t("item_card.more_details")}</Text>
</TouchableOpacity>
<BottomSheetModal
ref={bottomSheetModalRef}
@@ -52,14 +54,14 @@ 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">Video</Text>
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
<View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} />
</View>
</View>
<View className="">
<Text className="text-lg font-bold mb-2">Audio</Text>
<Text className="text-lg font-bold mb-2">{t("item_card.audio")}</Text>
<AudioStreamInfo
audioStreams={
source?.MediaStreams?.filter(
@@ -70,7 +72,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
</View>
<View className="">
<Text className="text-lg font-bold mb-2">Subtitles</Text>
<Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text>
<SubtitleStreamInfo
subtitleStreams={
source?.MediaStreams?.filter(

View File

@@ -0,0 +1,46 @@
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
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;
}
const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => {
const { servers, isSearching, startDiscovery } = useJellyfinDiscovery();
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")}
</Text>
</Button>
{servers.length ? (
<ListGroup title={t("server.servers")} className="mt-4">
{servers.map((server) => (
<ListItem
key={server.address}
onPress={() =>
onServerSelect?.({
address: server.address,
serverName: server.serverName,
})
}
title={server.address}
showArrow
/>
))}
</ListGroup>
) : null}
</View>
);
};
export default JellyfinServerDiscovery;

View File

@@ -1,13 +1,12 @@
import { tc } from "@/utils/textTools";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
@@ -29,6 +28,29 @@ export const MediaSourceSelector: React.FC<Props> = ({
[item, selected]
);
const { t } = useTranslation();
const commonPrefix = useMemo(() => {
const mediaSources = item.MediaSources || [];
if (!mediaSources.length) return "";
let commonPrefix = "";
for (let i = 0; i < mediaSources[0].Name!.length; i++) {
const char = mediaSources[0].Name![i];
if (mediaSources.every((source) => source.Name![i] === char)) {
commonPrefix += char;
} else {
commonPrefix = commonPrefix.slice(0, -1);
break;
}
}
return commonPrefix;
}, [item.MediaSources]);
const name = (name?: string | null) => {
return name?.replace(commonPrefix, "").toLowerCase();
};
return (
<View
className="flex shrink"
@@ -39,7 +61,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Video</Text>
<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">
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
@@ -63,9 +85,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
}}
>
<DropdownMenu.ItemTitle>
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
source.Size
)}`}
{`${name(source.Name)}`}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
@@ -74,9 +94,3 @@ export const MediaSourceSelector: React.FC<Props> = ({
</View>
);
};
const name = (name?: string | null) => {
if (name && name.length > 40)
return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
return name;
};

View File

@@ -11,6 +11,7 @@ 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 { useTranslation } from "react-i18next";
interface Props extends ViewProps {
actorId: string;
@@ -24,6 +25,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const { data: actor } = useQuery({
queryKey: ["actor", actorId],
@@ -76,7 +78,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
return (
<View {...props}>
<Text className="text-lg font-bold mb-2 px-4">
More with {actor?.Name}
{t("item_card.more_with", {name: actor?.Name})}
</Text>
<HorizontalScroll
data={items}

View File

@@ -2,6 +2,7 @@ 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";
interface Props extends ViewProps {
text?: string | null;
@@ -14,12 +15,13 @@ export const OverviewText: React.FC<Props> = ({
...props
}) => {
const [limit, setLimit] = useState(characterLimit);
const { t } = useTranslation();
if (!text) return null;
return (
<View className="flex flex-col" {...props}>
<Text className="text-lg font-bold mb-2">Overview</Text>
<Text className="text-lg font-bold mb-2">{t("item_card.overview")}</Text>
<TouchableOpacity
onPress={() =>
setLimit((prev) =>
@@ -31,7 +33,7 @@ export const OverviewText: React.FC<Props> = ({
<Text>{tc(text, limit)}</Text>
{text.length > characterLimit && (
<Text className="text-purple-600 mt-1">
{limit === characterLimit ? "Show more" : "Show less"}
{limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")}
</Text>
)}
</View>

View File

@@ -1,6 +1,6 @@
import { LinearGradient } from "expo-linear-gradient";
import { type PropsWithChildren, type ReactElement } from "react";
import { View, ViewProps } from "react-native";
import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native";
import Animated, {
interpolate,
useAnimatedRef,
@@ -13,6 +13,7 @@ interface Props extends ViewProps {
logo?: ReactElement;
episodePoster?: ReactElement;
headerHeight?: number;
onEndReached?: (() => void) | null | undefined;
}
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
@@ -21,6 +22,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
episodePoster,
headerHeight = 400,
logo,
onEndReached,
...props
}: Props) => {
const scrollRef = useAnimatedRef<Animated.ScrollView>();
@@ -47,6 +49,11 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
};
});
function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) {
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
}
return (
<View className="flex-1" {...props}>
<Animated.ScrollView
@@ -55,6 +62,10 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
}}
ref={scrollRef}
scrollEventThrottle={16}
onScroll={e => {
if (isCloseToBottom(e.nativeEvent))
onEndReached?.()
}}
>
{logo && (
<View

View File

@@ -32,7 +32,8 @@ import Animated, {
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import * as Haptics from "expo-haptics";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
@@ -50,6 +51,7 @@ export const PlayButton: React.FC<Props> = ({
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus();
const { t } = useTranslation();
const [colorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
@@ -64,6 +66,7 @@ export const PlayButton: React.FC<Props> = ({
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => {
@@ -79,7 +82,7 @@ export const PlayButton: React.FC<Props> = ({
const onPress = useCallback(async () => {
if (!item) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
const queryParams = new URLSearchParams({
itemId: item.Id!,
@@ -131,8 +134,8 @@ export const PlayButton: React.FC<Props> = ({
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
"Client error",
"Could not create stream for Chromecast"
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast")
);
return;
}

View File

@@ -3,6 +3,7 @@ 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;
@@ -22,11 +23,13 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
return JSON.parse(_previousServers || "[]") as Server[];
}, [_previousServers]);
const { t } = useTranslation();
if (!previousServers.length) return null;
return (
<View>
<ListGroup title="previous servers" className="mt-4">
<ListGroup title={t("server.previous_servers")} className="mt-4">
{previousServers.map((s) => (
<ListItem
key={s.address}
@@ -39,7 +42,7 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
onPress={() => {
setPreviousServers("[]");
}}
title={"Clear"}
title={t("server.clear_button")}
textColor="red"
/>
</ListGroup>

View File

@@ -6,7 +6,7 @@ import {
TouchableOpacity,
TouchableOpacityProps,
} from "react-native";
import * as Haptics from "expo-haptics";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends TouchableOpacityProps {
onPress?: () => void;
@@ -29,10 +29,11 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
}) => {
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
const lightHapticFeedback = useHaptic("light");
const handlePress = () => {
if (hapticFeedback) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
}
onPress?.();
};

View File

@@ -12,6 +12,7 @@ import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader";
import { HorizontalScroll } from "./common/HorrizontalScroll";
import { TouchableItemRouter } from "./common/TouchableItemRouter";
import { useTranslation } from "react-i18next";
interface SimilarItemsProps extends ViewProps {
itemId?: string | null;
@@ -23,6 +24,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({
queryKey: ["similarItems", itemId],
@@ -47,12 +49,12 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
return (
<View {...props}>
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
<Text className="px-4 text-lg font-bold mb-2">{t("item_card.similar_items")}</Text>
<HorizontalScroll
data={movies}
loading={isLoading}
height={247}
noItemsText="No similar items found"
noItemsText={t("item_card.no_similar_items_found")}
renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
key={idx}

View File

@@ -5,6 +5,7 @@ import { Platform, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
@@ -37,6 +38,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
if (subtitleStreams.length === 0) return null;
const { t } = useTranslation();
return (
<View
className="flex col shrink justify-start place-self-start items-start"
@@ -48,12 +51,12 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col " {...props}>
<Text className="opacity-50 mb-1 text-xs">Subtitle</Text>
<Text 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=" ">
{selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: "None"}
: t("item_card.none")}
</Text>
</TouchableOpacity>
</View>

View File

@@ -0,0 +1,108 @@
import * as DropdownMenu from "zeego/dropdown-menu";
import {TouchableOpacity, View, ViewProps} from "react-native";
import {Text} from "@/components/common/Text";
import React, {PropsWithChildren, ReactNode, useEffect, useState} from "react";
import DisabledSetting from "@/components/settings/DisabledSetting";
interface Props<T> {
data: T[]
disabled?: boolean
placeholderText?: string,
keyExtractor: (item: T) => string
titleExtractor: (item: T) => string | undefined
title: string | ReactNode,
label: string,
onSelected: (...item: T[]) => void
multi?: boolean
}
const Dropdown = <T extends unknown>({
data,
disabled,
placeholderText,
keyExtractor,
titleExtractor,
title,
label,
onSelected,
multi = false,
...props
}: PropsWithChildren<Props<T> & ViewProps>) => {
const [selected, setSelected] = useState<T[]>();
useEffect(() => {
if (selected !== undefined) {
onSelected(...selected)
}
}, [selected]);
return (
<DisabledSetting
disabled={disabled === true}
showText={false}
{...props}
>
<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}>
{selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText}
</Text>
</TouchableOpacity>
</View>
) : (
<>
{title}
</>
)}
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>{label}</DropdownMenu.Label>
{data.map((item, idx) => (
multi ? (
<DropdownMenu.CheckboxItem
value={selected?.some(s => keyExtractor(s) == keyExtractor(item)) ? 'on' : 'off'}
key={keyExtractor(item)}
onValueChange={(next, previous) =>
setSelected((p) => {
const prev = p || []
if (next == 'on') {
return [...prev, item]
}
return [...prev.filter(p => keyExtractor(p) !== keyExtractor(item))]
})
}
>
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
)
: (
<DropdownMenu.Item
key={keyExtractor(item)}
onSelect={() => setSelected([item])}
>
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</DisabledSetting>
)
};
export default Dropdown;

View File

@@ -15,6 +15,7 @@ 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"> {
@@ -136,7 +137,7 @@ export function InfiniteHorizontalScroll({
showsHorizontalScrollIndicator={false}
ListEmptyComponent={
<View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">No data available</Text>
<Text className="text-center text-gray-500">{t("item_card.no_data_available")}</Text>
</View>
}
{...props}

View File

@@ -7,7 +7,7 @@ export function Input(props: TextInputProps) {
return (
<TextInput
ref={inputRef}
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
className="p-4 rounded-xl bg-neutral-900"
allowFontScaling={false}
style={[{ color: "white" }, style]}
placeholderTextColor={"#9CA3AF"}

View File

@@ -4,8 +4,11 @@ import {
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter, useSegments } from "expo-router";
import { PropsWithChildren } from "react";
import { PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
import * as ContextMenu from "zeego/context-menu";
import { useActionSheet } from "@expo/react-native-action-sheet";
import * as Haptics from "expo-haptics";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
@@ -23,18 +26,6 @@ export const itemRouter = (
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
}
if (item.Type === "MusicAlbum") {
return `/(auth)/(tabs)/${from}/albums/${item.Id}`;
}
if (item.Type === "Audio") {
return `/(auth)/(tabs)/${from}/albums/${item.AlbumId}`;
}
if (item.Type === "MusicArtist") {
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
}
if (item.Type === "Person" || item.Type === "Actor") {
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
}
@@ -65,10 +56,33 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
}) => {
const router = useRouter();
const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed(item);
const from = segments[2];
const markAsPlayedStatus = useMarkAsPlayed(item);
const showActionSheet = useCallback(() => {
if (!(item.Type === "Movie" || item.Type === "Episode")) return;
const options = ["Mark as Played", "Mark as Not Played", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
},
async (selectedIndex) => {
if (selectedIndex === 0) {
await markAsPlayedStatus(true);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else if (selectedIndex === 1) {
await markAsPlayedStatus(false);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
}
);
}, [showActionSheetWithOptions, markAsPlayedStatus]);
if (
from === "(home)" ||
@@ -78,9 +92,10 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
)
return (
<TouchableOpacity
onLongPress={showActionSheet}
onPress={() => {
const url = itemRouter(item, from);
// @ts-ignore
// @ts-expect-error
router.push(url);
}}
{...props}

View File

@@ -1,7 +1,6 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
@@ -9,7 +8,6 @@ import { checkForExistingDownloads } from "@kesha-antonov/react-native-backgroun
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { FFmpegKit } from "ffmpeg-kit-react-native";
import { useAtom } from "jotai";
import {
ActivityIndicator,
TouchableOpacity,
@@ -22,6 +20,7 @@ import { Button } from "../Button";
import { Image } from "expo-image";
import { useMemo } from "react";
import { storage } from "@/utils/mmkv";
import { t } from "i18next";
interface Props extends ViewProps {}
@@ -30,14 +29,14 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
if (processes?.length === 0)
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Active download</Text>
<Text className="opacity-50">No active downloads</Text>
<Text className="text-lg font-bold">{t("home.downloads.active_download")}</Text>
<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">Active downloads</Text>
<Text className="text-lg font-bold mb-2">{t("home.downloads.active_downloads")}</Text>
<View className="space-y-2">
{processes?.map((p) => (
<DownloadCard key={p.item.Id} process={p} />
@@ -62,7 +61,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
mutationFn: async (id: string) => {
if (!process) throw new Error("No active download");
if (settings?.downloadMethod === "optimized") {
if (settings?.downloadMethod === DownloadMethod.Optimized) {
try {
const tasks = await checkForExistingDownloads();
for (const task of tasks) {
@@ -82,11 +81,11 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}
},
onSuccess: () => {
toast.success("Download canceled");
toast.success(t("home.downloads.toasts.download_cancelled"));
},
onError: (e) => {
console.error(e);
toast.error("Could not cancel download");
toast.error(t("home.downloads.toasts.could_not_cancel_download"));
},
});
@@ -153,7 +152,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
)}
{eta(process) && (
<Text className="text-xs">ETA {eta(process)}</Text>
<Text className="text-xs">{t("home.downloads.eta", {eta: eta(process)})}</Text>
)}
</View>

View File

@@ -1,5 +1,5 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics";
import { useHaptic } from "@/hooks/useHaptic";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import {
@@ -26,6 +26,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const successHapticFeedback = useHaptic("success");
const base64Image = useMemo(() => {
return storage.getString(item.Id!);
@@ -41,7 +42,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
successHapticFeedback();
}
}, [deleteFile, item.Id]);

View File

@@ -3,7 +3,7 @@ import {
useActionSheet,
} from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics";
import { useHaptic } from "@/hooks/useHaptic";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
@@ -28,6 +28,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const successHapticFeedback = useHaptic("success");
const handleOpenFile = useCallback(() => {
openFile(item);
@@ -43,7 +44,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
successHapticFeedback();
}
}, [deleteFile, item.Id]);

View File

@@ -1,7 +1,7 @@
import { Text } from "@/components/common/Text";
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { FilterSheet } from "./FilterSheet";

View File

@@ -19,6 +19,7 @@ import { StyleSheet, TouchableOpacity, View, ViewProps } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { useTranslation } from "react-i18next";
interface Props<T> extends ViewProps {
open: boolean;
@@ -76,6 +77,7 @@ export const FilterSheet = <T,>({
}: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["80%"], []);
const { t } = useTranslation();
const [data, setData] = useState<T[]>([]);
const [offset, setOffset] = useState<number>(0);
@@ -153,10 +155,10 @@ export const FilterSheet = <T,>({
>
<View className="px-4 mt-2 mb-8">
<Text className="font-bold text-2xl">{title}</Text>
<Text className="mb-2 text-neutral-500">{_data?.length} items</Text>
<Text className="mb-2 text-neutral-500">{t("search.items", {count: _data?.length})}</Text>
{showSearch && (
<Input
placeholder="Search..."
placeholder={t("search.search")}
className="my-2"
value={search}
onChangeText={(text) => {

View File

@@ -5,6 +5,7 @@ import { View } from "react-native";
import { ScrollingCollectionList } from "./ScrollingCollectionList";
import { useCallback } from "react";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
import { t } from "i18next";
export const Favorites = () => {
const [api] = useAtom(apiAtom);
@@ -54,64 +55,44 @@ export const Favorites = () => {
() => fetchFavoritesByType("Playlist"),
[fetchFavoritesByType]
);
const fetchFavoriteMusicAlbum = useCallback(
() => fetchFavoritesByType("MusicAlbum"),
[fetchFavoritesByType]
);
const fetchFavoriteAudio = useCallback(
() => fetchFavoritesByType("Audio"),
[fetchFavoritesByType]
);
return (
<View className="flex flex-co gap-y-4">
<ScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]}
title="Series"
title={t("favorites.series")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
title="Movies"
title={t("favorites.movies")}
hideIfEmpty
orientation="vertical"
/>
<ScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
title="Episodes"
title={t("favorites.episodes")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
title="Videos"
title={t("favorites.videos")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
title="Boxsets"
title={t("favorites.boxsets")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
title="Playlists"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteMusicAlbum}
queryKey={["home", "favorites", "musicAlbums"]}
title="Music Albums"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteAudio}
queryKey={["home", "favorites", "audio"]}
title="Audio"
title={t("favorites.playlists")}
hideIfEmpty
/>
</View>

View File

@@ -1,3 +1,4 @@
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
@@ -6,9 +7,11 @@ import { 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, TouchableOpacity, View, ViewProps } from "react-native";
import { Dimensions, View, ViewProps } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
runOnJS,
useSharedValue,
@@ -18,11 +21,7 @@ import Carousel, {
ICarouselInstance,
Pagination,
} from "react-native-reanimated-carousel";
import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
import { Loader } from "../Loader";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { useRouter, useSegments } from "expo-router";
import * as Haptics from "expo-haptics";
import { itemRouter } from "../common/TouchableItemRouter";
interface Props extends ViewProps {}
@@ -128,6 +127,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const [api] = useAtom(apiAtom);
const router = useRouter();
const screenWidth = Dimensions.get("screen").width;
const lightHapticFeedback = useHaptic("light");
const uri = useMemo(() => {
if (!api) return null;
@@ -153,7 +153,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const handleRoute = useCallback(() => {
if (!from) return;
const url = itemRouter(item, from);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
// @ts-ignore
if (url) router.push(url);
}, [item, from]);
@@ -161,7 +161,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const tap = Gesture.Tap()
.maxDuration(2000)
.onBegin(() => {
opacity.value = withTiming(0.5, { duration: 100 });
opacity.value = withTiming(0.8, { duration: 100 });
})
.onEnd(() => {
runOnJS(handleRoute)();

View File

@@ -11,6 +11,7 @@ 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;
@@ -39,9 +40,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
refetchOnReconnect: true,
});
if (disabled || !title) return null;
const { t } = useTranslation();
if (hideIfEmpty === true && data?.length === 0) return null;
if (disabled || !title) return null;
return (
<View {...props}>
@@ -50,7 +52,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
</Text>
{isLoading === false && data?.length === 0 && (
<View className="px-4">
<Text className="text-neutral-500">No items</Text>
<Text className="text-neutral-500">{t("home.no_items")}</Text>
</View>
)}
{isLoading ? (
@@ -104,7 +106,12 @@ export const ScrollingCollectionList: React.FC<Props> = ({
{item.Type === "Movie" && orientation === "vertical" && (
<MoviePoster item={item} />
)}
{item.Type === "Series" && <SeriesPoster item={item} />}
{item.Type === "Series" && orientation === "vertical" && (
<SeriesPoster item={item} />
)}
{item.Type === "Series" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Program" && (
<ContinueWatchingPoster item={item} />
)}

View File

@@ -1,8 +1,10 @@
import {TouchableOpacity, View} from "react-native";
import {Text} from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
interface StepperProps {
value: number,
disabled?: boolean,
step: number,
min: number,
max: number,
@@ -12,6 +14,7 @@ interface StepperProps {
export const Stepper: React.FC<StepperProps> = ({
value,
disabled,
step,
min,
max,
@@ -19,7 +22,11 @@ export const Stepper: React.FC<StepperProps> = ({
appendValue
}) => {
return (
<View className="flex flex-row items-center">
<DisabledSetting
disabled={disabled === true}
showText={false}
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"
@@ -39,6 +46,6 @@ export const Stepper: React.FC<StepperProps> = ({
>
<Text>+</Text>
</TouchableOpacity>
</View>
</DisabledSetting>
)
}

View File

@@ -0,0 +1,41 @@
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 { useTranslation } from "react-i18next";
const CastSlide: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, ...props }) => {
const { t } = useTranslation();
return (
details?.credits?.cast &&
details?.credits?.cast?.length > 0 && (
<View {...props}>
<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" />}
estimatedItemSize={15}
keyExtractor={(item) => item?.id?.toString()}
contentContainerStyle={{ paddingHorizontal: 16 }}
renderItem={({ item }) => (
<PersonPoster
id={item.id.toString()}
posterPath={item.profilePath}
name={item.name}
subName={item.character}
/>
)}
/>
</View>
)
);
};
export default CastSlide;

View File

@@ -0,0 +1,220 @@
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 { useTranslation } from "react-i18next";
interface Release {
certification: string;
iso_639_1?: string;
note?: string;
release_date: string;
type: number;
}
const dateOpts: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
};
const Facts: React.FC<
{ title: string; facts?: string[] | React.ReactNode[] } & ViewProps
> = ({ 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 items-end">
{facts.map((f, idx) =>
typeof f === "string" ? <Text key={idx}>{f}</Text> : f
)}
</View>
</View>
);
const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
title,
fact,
...props
}) => fact && <Facts title={title} facts={[fact]} {...props} />;
const DetailFacts: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, className, ...props }) => {
const { jellyseerrUser } = useJellyseerr();
const { t } = useTranslation();
const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
const region = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const releases = useMemo(
() =>
(details as MovieDetails)?.releases?.results.find(
(r: TmdbRelease) => r.iso_3166_1 === region
)?.release_dates as TmdbRelease["release_dates"],
[details]
);
// Release date types:
// 1. Premiere
// 2. Theatrical (limited)
// 3. Theatrical
// 4. Digital
// 5. Physical
// 6. TV
const filteredReleases = useMemo(
() =>
uniqBy(
releases?.filter((r: Release) => r.type > 2 && r.type < 6),
"type"
),
[releases]
);
const firstAirDate = useMemo(() => {
const firstAirDate = (details as TvDetails)?.firstAirDate;
if (firstAirDate) {
return new Date(firstAirDate).toLocaleDateString(
`${locale}-${region}`,
dateOpts
);
}
}, [details]);
const nextAirDate = useMemo(() => {
const firstAirDate = (details as TvDetails)?.firstAirDate;
const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate;
if (nextAirDate && firstAirDate !== nextAirDate) {
return new Date(nextAirDate).toLocaleDateString(
`${locale}-${region}`,
dateOpts
);
}
}, [details]);
const revenue = useMemo(
() =>
(details as MovieDetails)?.revenue?.toLocaleString?.(
`${locale}-${region}`,
{ style: "currency", currency: "USD" }
),
[details]
);
const budget = useMemo(
() =>
(details as MovieDetails)?.budget?.toLocaleString?.(
`${locale}-${region}`,
{ style: "currency", currency: "USD" }
),
[details]
);
const streamingProviders = useMemo(
() =>
details?.watchProviders?.find(
(provider) => provider.iso_3166_1 === region
)?.flatrate,
[details]
);
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
const spokenLanguage = useMemo(
() =>
details?.spokenLanguages.find(
(lng) => lng.iso_639_1 === details.originalLanguage
)?.name,
[details]
);
return (
details && (
<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}
>
<Fact title={t("jellyseerr.status")} fact={details?.status} />
<Fact
title={t("jellyseerr.original_title")}
fact={(details as TvDetails)?.originalName}
/>
{details.keywords.some(
(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">
{r.type === 3 ? (
// Theatrical
<Ionicons name="ticket" size={16} color="white" />
) : r.type === 4 ? (
// Digital
<Ionicons name="cloud" size={16} color="white" />
) : (
// Physical
<MaterialCommunityIcons
name="record-circle-outline"
size={16}
color="white"
/>
)}
<Text>
{new Date(r.release_date).toLocaleDateString(
`${locale}-${region}`,
dateOpts
)}
</Text>
</View>
))}
/>
<Fact title={t("jellyseerr.first_air_date")} fact={firstAirDate} />
<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} />
<Facts
title={t("jellyseerr.production_country")}
facts={details?.productionCountries?.map((n, idx) => (
<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>
))}
/>
<Facts
title={t("jellyseerr.studios")}
facts={uniqBy(details?.productionCompanies, "name")?.map(
(n) => n.name
)}
/>
<Facts title={t("jellyseerr.network")}facts={networks?.map((n) => n.name)} />
<Facts
title={t("jellyseerr.currently_streaming_on")}
facts={streamingProviders?.map((s) => s.name)}
/>
</View>
</View>
)
);
};
export default DetailFacts;

View File

@@ -0,0 +1,161 @@
import Discover from "@/components/jellyseerr/discover/Discover";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {
MovieResult,
PersonResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
import React, { useMemo } from "react";
import { View, ViewProps } from "react-native";
import {
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Text } from "../common/Text";
import JellyseerrPoster from "../posters/JellyseerrPoster";
import { LoadingSkeleton } from "../search/LoadingSkeleton";
import { SearchItemWrapper } from "../search/SearchItemWrapper";
import PersonPoster from "./PersonPoster";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps {
searchQuery: string;
}
export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => {
const { jellyseerrApi } = useJellyseerr();
const opacity = useSharedValue(1);
const { t } = useTranslation();
const {
data: jellyseerrDiscoverSettings,
isFetching: f1,
isLoading: l1,
} = useReactNavigationQuery({
queryKey: ["search", "jellyseerr", "discoverSettings", searchQuery],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled: !!jellyseerrApi && searchQuery.length == 0,
});
const {
data: jellyseerrResults,
isFetching: f2,
isLoading: l2,
} = useReactNavigationQuery({
queryKey: ["search", "jellyseerr", "results", searchQuery],
queryFn: async () => {
const response = await jellyseerrApi?.search({
query: new URLSearchParams(searchQuery).toString(),
page: 1,
language: "en",
});
return response?.results;
},
enabled: !!jellyseerrApi && searchQuery.length > 0,
});
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: opacity.value,
};
});
useAnimatedReaction(
() => f1 || f2 || l1 || l2,
(isLoading) => {
if (isLoading) {
opacity.value = withTiming(1, { duration: 200 });
} else {
opacity.value = withTiming(0, { duration: 200 });
}
}
);
const jellyseerrMovieResults = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.MOVIE
) as MovieResult[],
[jellyseerrResults]
);
const jellyseerrTvResults = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.TV
) as TvResult[],
[jellyseerrResults]
);
const jellyseerrPersonResults = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === "person"
) as PersonResult[],
[jellyseerrResults]
);
if (!searchQuery.length)
return (
<View className="flex flex-col">
<Discover sliders={jellyseerrDiscoverSettings} />
</View>
);
return (
<View>
<LoadingSkeleton isLoading={f1 || f2 || l1 || l2} />
{!jellyseerrMovieResults?.length &&
!jellyseerrTvResults?.length &&
!jellyseerrPersonResults?.length &&
!f1 &&
!f2 &&
!l1 &&
!l2 && (
<View>
<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">
"{searchQuery}"
</Text>
</View>
)}
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header={t("search.request_movies")}
items={jellyseerrMovieResults}
renderItem={(item: MovieResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
header={t("search.request_series")}
items={jellyseerrTvResults}
renderItem={(item: TvResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
header={t("search.actors")}
items={jellyseerrPersonResults}
renderItem={(item: PersonResult) => (
<PersonPoster
className="mr-2"
key={item.id}
id={item.id.toString()}
name={item.name}
posterPath={item.profilePath}
/>
)}
/>
</View>
</View>
);
};

View File

@@ -0,0 +1,37 @@
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";
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]
);
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>
)
}
export default JellyseerrMediaIcon;

View File

@@ -2,7 +2,6 @@ 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 {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
interface Props {
mediaStatus?: MediaStatus;
@@ -10,7 +9,7 @@ interface Props {
onPress?: () => void;
}
const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
mediaStatus,
showRequestIcon,
onPress,
@@ -69,4 +68,4 @@ const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
)
}
export default JellyseerrIconStatus;
export default JellyseerrStatusIcon;

View File

@@ -0,0 +1,160 @@
import React, {
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 { Animated } from "react-native";
import { FlashList } from "@shopify/flash-list";
import {useFocusEffect} from "expo-router";
const ANIMATION_ENTER = 250;
const ANIMATION_EXIT = 250;
const BACKDROP_DURATION = 5000;
type Render = React.ComponentType<any>
| React.ReactElement
| null
| undefined;
interface Props<T> {
data: T[]
images: string[];
logo?: React.ReactElement;
HeaderContent?: () => React.ReactElement;
MainContent?: () => React.ReactElement;
listHeader: string;
renderItem: (item: T, index: number) => Render;
keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null | undefined;
}
const ParallaxSlideShow = <T extends unknown>({
data,
images,
logo,
HeaderContent,
MainContent,
listHeader,
renderItem,
keyExtractor,
onEndReached,
...props
}: PropsWithChildren<Props<T> & ViewProps>
) => {
const insets = useSafeAreaInsets();
const [currentIndex, setCurrentIndex] = useState(0);
const fadeAnim = useRef(new Animated.Value(0)).current;
const enterAnimation = useCallback(
() =>
Animated.timing(fadeAnim, {
toValue: 1,
duration: ANIMATION_ENTER,
useNativeDriver: true,
}),
[fadeAnim]
);
const exitAnimation = useCallback(
() =>
Animated.timing(fadeAnim, {
toValue: 0,
duration: ANIMATION_EXIT,
useNativeDriver: true,
}),
[fadeAnim]
);
useEffect(() => {
if (images?.length) {
enterAnimation().start();
const intervalId = setInterval(() => {
Animated.sequence([
enterAnimation(),
exitAnimation()
]).start(() => {
fadeAnim.setValue(0);
setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
})
}, BACKDROP_DURATION);
return () => {
clearInterval(intervalId)
};
}
}, [fadeAnim, images, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className="flex-1 opacity-100"
headerHeight={300}
onEndReached={onEndReached}
headerImage={
<Animated.Image
key={images?.[currentIndex]}
id={images?.[currentIndex]}
source={{
uri: images?.[currentIndex],
}}
style={{
width: "100%",
height: "100%",
opacity: fadeAnim,
}}
/>
}
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">
{HeaderContent && HeaderContent()}
</View>
</View>
{MainContent && MainContent()}
<View>
<FlashList
data={data}
ListEmptyComponent={
<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"
ListHeaderComponent={
<Text className="text-lg font-bold my-2">{listHeader}</Text>
}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
//@ts-ignore
renderItem={({ item, index}) => renderItem(item, index)}
keyExtractor={keyExtractor}
numColumns={3}
estimatedItemSize={214}
ItemSeparatorComponent={() => <View className="h-2 w-2" />}
/>
</View>
</View>
</ParallaxScrollView>
</View>
);
}
export default ParallaxSlideShow;

View File

@@ -0,0 +1,42 @@
import {TouchableOpacity, View, ViewProps} from "react-native";
import React from "react";
import {Text} from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
import {useRouter, useSegments} from "expo-router";
import {useJellyseerr} from "@/hooks/useJellyseerr";
interface Props {
id: string
posterPath?: string
name: string
subName?: string
}
const PersonPoster: React.FC<Props & ViewProps> = ({
id,
posterPath,
name,
subName,
...props
}) => {
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}>
<Poster
id={id}
url={jellyseerrApi?.imageProxy(posterPath, 'w600_and_h900_bestv2')}
/>
<Text className="mt-2">{name}</Text>
{subName && <Text className="text-xs opacity-50">{subName}</Text>}
</View>
</TouchableOpacity>
)
}
export default PersonPoster;

View File

@@ -0,0 +1,236 @@
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 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 { useTranslation } from "react-i18next";
interface Props {
id: number;
title: string,
type: MediaType;
isAnime?: boolean;
is4k?: boolean;
onRequested?: () => void;
}
const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps, 'id'>>(({
id,
title,
type,
isAnime = false,
onRequested,
...props
}, ref) => {
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr();
const [requestOverrides, setRequestOverrides] =
useState<MediaRequestBody>({
mediaId: Number(id),
mediaType: type,
userId: jellyseerrUser?.id
});
const { t } = useTranslation();
const [modalRequestProps, setModalRequestProps] = useState<MediaRequestBody>();
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 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 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 defaultTags: Tag[] = useMemo(
() => {
const tags = defaultServiceDetails?.tags
.filter(t =>
(isAnime
? defaultServiceDetails?.server.activeAnimeTags
: defaultServiceDetails?.server.activeTags
)?.includes(t.id)
) ?? []
console.log(tags)
return tags
},
[defaultServiceDetails]
);
const seasonTitle = useMemo(
() => modalRequestProps?.seasons?.length ? t("jellyseerr.season_x", {seasons: modalRequestProps?.seasons}) : undefined,
[modalRequestProps?.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),
...modalRequestProps,
...requestOverrides
},
onRequested
)
}, [requestOverrides, defaultProfile, defaultFolder, defaultTags]);
const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`;
return (
<BottomSheetModal
ref={ref}
enableDynamicSizing
enableDismissOnClose
onDismiss={() => setModalRequestProps(undefined)}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(sheetProps: BottomSheetBackdropProps) =>
<BottomSheetBackdrop
{...sheetProps}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
}
>
{(data) => {
setModalRequestProps(data?.data as MediaRequestBody)
return <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={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
multi={true}
data={defaultServiceDetails.tags}
titleExtractor={(item) => item.label}
placeholderText={defaultTags.map(t => t.label).join(",")}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.tags")}
onSelected={(...item) =>
item && setRequestOverrides((prev) => ({
...prev,
tags: item.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>
);
});
export default RequestModal;

View File

@@ -0,0 +1,51 @@
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
COMPANY_LOGO_IMAGE_FILTER,
Network,
} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import { 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";
const CompanySlide: React.FC<
{ data: Network[] | Studio[] } & SlideProps & ViewProps
> = ({ slide, data, ...props }) => {
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
const from = segments[2];
const navigate = useCallback(
({ id, image, name }: Network | Studio) =>
router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
params: { id, image, name, type: slide.type },
}),
[slide]
);
return (
<Slide
{...props}
slide={slide}
data={data}
keyExtractor={(item) => item.id.toString()}
renderItem={(item, index) => (
<TouchableOpacity className="mr-2" onPress={() => navigate(item)}>
<GenericSlideCard
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
)}
/>
</TouchableOpacity>
)}
/>
);
};
export default CompanySlide;

View File

@@ -0,0 +1,47 @@
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";
interface Props {
sliders?: DiscoverSlider[];
}
const Discover: React.FC<Props> = ({ sliders }) => {
if (!sliders)
return;
const sortedSliders = useMemo(
() => sortBy(sliders.filter((s) => s.enabled), 'order', 'asc'),
[sliders]
);
return (
<View className="flex flex-col space-y-4 mb-8">
{sortedSliders.map(slide => {
switch (slide.type) {
case DiscoverSliderType.NETWORKS:
return <CompanySlide key={slide.id} slide={slide} data={networks}/>
case DiscoverSliderType.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} />
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}/>
}
})}
</View>
)
};
export default Discover;

View File

@@ -0,0 +1,59 @@
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";
export const textShadowStyle = StyleSheet.create({
shadow: {
shadowColor: "#000",
shadowOffset: {
width: 1,
height: 1,
},
shadowOpacity: 1,
shadowRadius: .5,
elevation: 6,
}
})
const GenericSlideCard: React.FC<{id: string; url?: string, title?: string, colors?: string[], contentFit?: ImageContentFit} & ViewProps> = ({
id,
url,
title,
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"
>
<View className="rounded-xl" {...props}>
<Image
key={id}
id={id}
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"
>
<Text className="text-center font-bold" style={textShadowStyle.shadow}>{title}</Text>
</View>
}
</View>
</LinearGradient>
</>
);
export default GenericSlideCard;

View File

@@ -0,0 +1,67 @@
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import Slide, { 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 { 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";
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
const from = segments[2];
const navigate = useCallback(
(genre: GenreSliderItem) =>
router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`,
params: { type: slide.type, name: genre.name },
}),
[slide]
);
const { data, isFetching, isLoading } = useQuery({
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
queryFn: async () => {
return jellyseerrApi?.getGenreSliders(
slide.type == DiscoverSliderType.MOVIE_GENRES
? Endpoints.MOVIE
: Endpoints.TV
);
},
enabled: !!jellyseerrApi,
});
return (
data && (
<Slide
{...props}
slide={slide}
data={data}
keyExtractor={(item) => item.id.toString()}
renderItem={(item, index) => (
<TouchableOpacity className="mr-2" onPress={() => navigate(item)}>
<GenericSlideCard
className="w-28 rounded-lg overflow-hidden border border-neutral-900"
id={item.id.toString()}
title={item.name}
colors={[]}
contentFit={"cover"}
url={jellyseerrApi?.imageProxy(
item.backdrops?.[0],
`w780_filter(duotone,${
genreColorMap[item.id] ?? genreColorMap[0]
})`
)}
/>
</TouchableOpacity>
)}
/>
)
);
};
export default GenreSlide;

View File

@@ -1,5 +1,4 @@
import React, { useMemo } from "react";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import React, {useMemo} from "react";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
DiscoverEndpoint,
@@ -9,17 +8,13 @@ import {
import { useInfiniteQuery } from "@tanstack/react-query";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Text } from "@/components/common/Text";
import { FlashList } from "@shopify/flash-list";
import { View } from "react-native";
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
import {ViewProps} from "react-native";
interface Props {
slide: DiscoverSlider;
}
const DiscoverSlide: React.FC<Props> = ({ slide }) => {
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const { jellyseerrApi } = useJellyseerr();
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "discover", slide.id],
queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined = undefined;
@@ -62,42 +57,28 @@ const DiscoverSlide: React.FC<Props> = ({ slide }) => {
});
const flatData = useMemo(
() =>
data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
() => data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
[data]
);
return (
flatData &&
flatData?.length > 0 && (
<View className="mb-4">
<Text className="font-bold text-lg mb-2 px-4">
{DiscoverSliderType[slide.type].toString().toTitle()}
</Text>
<FlashList
horizontal
contentContainerStyle={{
paddingLeft: 16,
}}
showsHorizontalScrollIndicator={false}
keyExtractor={(item) => item!!.id.toString()}
estimatedItemSize={250}
data={flatData}
onEndReachedThreshold={1}
onEndReached={() => {
if (hasNextPage) fetchNextPage();
}}
renderItem={({ item }) =>
item ? (
<JellyseerrPoster item={item as MovieResult | TvResult} />
) : (
<></>
)
}
/>
</View>
<Slide
{...props}
slide={slide}
data={flatData}
keyExtractor={(item) => item!!.id.toString()}
onEndReached={() => {
if (hasNextPage)
fetchNextPage()
}}
renderItem={(item) =>
<JellyseerrPoster item={item as MovieResult | TvResult} />
}
/>
)
);
};
export default DiscoverSlide;
export default MovieTvSlide;

View File

@@ -0,0 +1,56 @@
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 { FlashList } from "@shopify/flash-list";
import {View, ViewProps} from "react-native";
import { t } from "i18next";
export interface SlideProps {
slide: DiscoverSlider;
}
interface Props<T> extends SlideProps {
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>({
data,
slide,
renderItem,
keyExtractor,
onEndReached,
...props
}: PropsWithChildren<Props<T> & ViewProps>
) => {
return (
<View {...props}>
<Text className="font-bold text-lg mb-2 px-4">
{t("search." + DiscoverSliderType[slide.type].toString().toLowerCase())}
</Text>
<FlashList
horizontal
contentContainerStyle={{
paddingHorizontal: 16,
}}
showsHorizontalScrollIndicator={false}
keyExtractor={keyExtractor}
estimatedItemSize={250}
data={data}
onEndReachedThreshold={1}
onEndReached={onEndReached}
//@ts-ignore
renderItem={({item, index}) => item ? renderItem(item, index) : <></>}
/>
</View>
);
};
export default Slide;

View File

@@ -15,6 +15,7 @@ import { useAtom } from "jotai";
import { useMemo } from "react";
import { TouchableOpacityProps, View } from "react-native";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { useTranslation } from "react-i18next";
interface Props extends TouchableOpacityProps {
library: BaseItemDto;
@@ -42,6 +43,8 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
const [user] = useAtom(userAtom);
const [settings] = useSettings();
const { t } = useTranslation();
const url = useMemo(
() =>
getPrimaryImageUrl({
@@ -60,8 +63,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
_itemType = "Series";
} else if (library.CollectionType === "boxsets") {
_itemType = "BoxSet";
} else if (library.CollectionType === "music") {
_itemType = "MusicAlbum";
}
return _itemType;
@@ -71,15 +72,13 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
let nameStr: string;
if (library.CollectionType === "movies") {
nameStr = "movies";
nameStr = t("library.item_types.movies");
} else if (library.CollectionType === "tvshows") {
nameStr = "series";
nameStr = t("library.item_types.series");
} else if (library.CollectionType === "boxsets") {
nameStr = "box sets";
} else if (library.CollectionType === "music") {
nameStr = "albums";
nameStr = t("library.item_types.boxsets");
} else {
nameStr = "items";
nameStr = t("library.item_types.items");
}
return nameStr;

View File

@@ -1,3 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
import { PropsWithChildren, ReactNode } from "react";
import {
TouchableOpacity,
@@ -6,7 +7,6 @@ import {
ViewProps,
} from "react-native";
import { Text } from "../common/Text";
import { Ionicons } from "@expo/vector-icons";
interface Props extends TouchableOpacityProps, ViewProps {
title?: string | null | undefined;

View File

@@ -1,35 +0,0 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter } from "expo-router";
import { View, ViewProps } from "react-native";
import { SongsListItem } from "./SongsListItem";
interface Props extends ViewProps {
songs?: BaseItemDto[] | null;
collectionId: string;
artistId: string;
albumId: string;
}
export const SongsList: React.FC<Props> = ({
collectionId,
artistId,
albumId,
songs = [],
...props
}) => {
const router = useRouter();
return (
<View className="flex flex-col space-y-2" {...props}>
{songs?.map((item: BaseItemDto, index: number) => (
<SongsListItem
key={item.Id}
item={item}
index={index}
collectionId={collectionId}
artistId={artistId}
albumId={albumId}
/>
))}
</View>
);
};

View File

@@ -1,128 +0,0 @@
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { runtimeTicksToSeconds } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
interface Props extends TouchableOpacityProps {
collectionId: string;
artistId: string;
albumId: string;
item: BaseItemDto;
index: number;
}
export const SongsListItem: React.FC<Props> = ({
collectionId,
artistId,
albumId,
item,
index,
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const router = useRouter();
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
const { setPlaySettings } = usePlaySettings();
const openSelect = () => {
if (!castDevice?.deviceId) {
play("device");
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
},
(selectedIndex: number | undefined) => {
switch (selectedIndex) {
case 0:
play("cast");
break;
case 1:
play("device");
break;
case cancelButtonIndex:
break;
}
}
);
};
const play = useCallback(async (type: "device" | "cast") => {
if (!user?.Id || !api || !item.Id) {
console.warn("No user, api or item", user, api, item.Id);
return;
}
const data = await setPlaySettings({
item,
});
if (!data?.url) {
throw new Error("play-music ~ No stream url");
}
if (type === "cast" && client) {
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: data.url!,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
},
startTime: 0,
});
}
});
} else {
console.log("Playing on device", data.url, item.Id);
router.push("/music-player");
}
}, []);
return (
<TouchableOpacity
onPress={() => {
openSelect();
}}
{...props}
>
<View className="flex flex-row items-center space-x-4 bg-neutral-900 border-neutral-800 px-4 py-4 rounded-xl">
<Text className="opacity-50">{index + 1}</Text>
<View>
<Text className="mb-0.5 font-semibold">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{runtimeTicksToSeconds(item.RunTimeTicks)}
</Text>
</View>
</View>
</TouchableOpacity>
);
};

View File

@@ -1,82 +0,0 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
type ArtistPosterProps = {
item?: BaseItemDto | null;
id?: string | null;
showProgress?: boolean;
};
const AlbumCover: React.FC<ArtistPosterProps> = ({ item, id }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(() => {
const u = getPrimaryImageUrl({
api,
item,
});
return u;
}, [item]);
const url2 = useMemo(() => {
const u = getPrimaryImageUrlById({
api,
id,
quality: 85,
width: 300,
});
return u;
}, [item]);
if (!item && id)
return (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<Image
key={id}
id={id}
source={
url2
? {
uri: url2,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
if (item)
return (
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
};
export default AlbumCover;

View File

@@ -1,57 +0,0 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
type ArtistPosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
const ArtistPoster: React.FC<ArtistPosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() =>
getPrimaryImageUrl({
api,
item,
}),
[item]
);
if (!url)
return (
<View
className="rounded-lg overflow-hidden border border-neutral-900"
style={{
aspectRatio: "1/1",
}}
></View>
);
return (
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
};
export default ArtistPoster;

View File

@@ -1,55 +1,63 @@
import {View, ViewProps} from "react-native";
import {Image} from "expo-image";
import {MaterialCommunityIcons} from "@expo/vector-icons";
import {Text} from "@/components/common/Text";
import {useEffect, useMemo, useState} from "react";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
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 { MediaType } from "@/utils/jellyseerr/server/constants/media";
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";
interface Props extends ViewProps {
item: MovieResult | TvResult;
}
const JellyseerrPoster: React.FC<Props> = ({
item,
...props
}) => {
const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
// const imageSource =
const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
const { jellyseerrApi } = useJellyseerr();
const loadingOpacity = useSharedValue(1);
const imageOpacity = useSharedValue(0);
const imageSrc = useMemo(() =>
item.posterPath ?
`https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}`
: jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`,
const loadingAnimatedStyle = useAnimatedStyle(() => ({
opacity: loadingOpacity.value,
}));
const imageAnimatedStyle = useAnimatedStyle(() => ({
opacity: imageOpacity.value,
}));
const handleImageLoad = () => {
loadingOpacity.value = withTiming(0, { duration: 200 });
imageOpacity.value = withTiming(1, { duration: 300 });
};
const imageSrc = useMemo(
() => jellyseerrApi?.imageProxy(item.posterPath, "w300_and_h450_face"),
[item, jellyseerrApi]
)
const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item])
const releaseYear = useMemo(() =>
new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(),
);
const title = useMemo(
() => (item.mediaType === MediaType.MOVIE ? item.title : item.name),
[item]
)
);
const showRequestButton = useMemo(() =>
jellyseerrUser && hasPermission(
[
Permission.REQUEST,
item.mediaType === 'movie'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
],
jellyseerrUser.permissions,
{type: 'or'}
),
[item, jellyseerrUser]
)
const releaseYear = useMemo(
() =>
new Date(
item.mediaType === MediaType.MOVIE
? item.releaseDate
: item.firstAirDate
).getFullYear(),
[item]
);
const canRequest = useMemo(() => {
const status = item?.mediaInfo?.status
return showRequestButton && !status || status === MediaStatus.UNKNOWN
}, [item])
const [canRequest] = useJellyseerrCanRequest(item);
return (
<TouchableJellyseerrRouter
@@ -57,36 +65,41 @@ const JellyseerrPoster: React.FC<Props> = ({
mediaTitle={title}
releaseYear={releaseYear}
canRequest={canRequest}
posterSrc={imageSrc}
posterSrc={imageSrc!!}
>
<View className="flex flex-col w-28 mr-2">
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
<Image
key={item.id}
id={item.id.toString()}
source={{uri: imageSrc}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
width: "100%",
}}
/>
<JellyseerrIconStatus
<Animated.View style={imageAnimatedStyle}>
<Image
key={item.id}
id={item.id.toString()}
source={{ uri: imageSrc }}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
width: "100%",
}}
onLoad={handleImageLoad}
/>
</Animated.View>
<JellyseerrStatusIcon
className="absolute bottom-1 right-1"
showRequestIcon={canRequest}
mediaStatus={item?.mediaInfo?.status}
/>
<JellyseerrMediaIcon
className="absolute top-1 left-1"
mediaType={item?.mediaType}
/>
</View>
<View className="mt-2 flex flex-col">
<Text numberOfLines={2}>{title}</Text>
<Text className="text-xs opacity-50">{releaseYear}</Text>
<Text className="text-xs opacity-50 align-bottom">{releaseYear}</Text>
</View>
</View>
</TouchableJellyseerrRouter>
)
}
);
};
export default JellyseerrPoster;
export default JellyseerrPoster;

View File

@@ -1,19 +1,15 @@
import {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { View } from "react-native";
type PosterProps = {
item?: BaseItemDto | BaseItemPerson | null;
id?: string | null;
url?: string | null;
showProgress?: boolean;
blurhash?: string | null;
};
const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
if (!item)
const Poster: React.FC<PosterProps> = ({ id, url, blurhash }) => {
if (!id && !url)
return (
<View
className="border border-neutral-900"
@@ -33,8 +29,8 @@ const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
}
: null
}
key={item.Id}
id={item.Id}
key={id}
id={id!!}
source={
url
? {

View File

@@ -0,0 +1,66 @@
import { View } from "react-native";
import { Text } from "../common/Text";
import Animated, {
useAnimatedStyle,
useAnimatedReaction,
useSharedValue,
withTiming,
} from "react-native-reanimated";
interface Props {
isLoading: boolean;
}
export const LoadingSkeleton: React.FC<Props> = ({ isLoading }) => {
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: opacity.value,
};
});
useAnimatedReaction(
() => isLoading,
(loading) => {
if (loading) {
opacity.value = withTiming(1, { duration: 200 });
} else {
opacity.value = withTiming(0, { duration: 200 });
}
}
);
return (
<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">
{[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">
<Text
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">
<Text
className="text-neutral-900 bg-neutral-900 text-xs rounded-md"
numberOfLines={1}
>
Lorem ipsum
</Text>
</View>
</View>
))}
</View>
</View>
))}
</Animated.View>
);
};

View File

@@ -0,0 +1,70 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { PropsWithChildren } from "react";
import { ScrollView } from "react-native";
import { Text } from "../common/Text";
type SearchItemWrapperProps<T> = {
ids?: string[] | null;
items?: T[];
renderItem: (item: any) => React.ReactNode;
header?: string;
};
export const SearchItemWrapper = <T extends unknown>({
ids,
items,
renderItem,
header,
}: PropsWithChildren<SearchItemWrapperProps<T>>) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data, isLoading: l1 } = useQuery({
queryKey: ["items", ids],
queryFn: async () => {
if (!user?.Id || !api || !ids || ids.length === 0) {
return [];
}
const itemPromises = ids.map((id) =>
getUserItemData({
api,
userId: user.Id,
itemId: id,
})
);
const results = await Promise.all(itemPromises);
// Filter out null items
return results.filter(
(item) => item !== null
) as unknown as BaseItemDto[];
},
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
staleTime: Infinity,
});
if (!data && (!items || items.length === 0)) return null;
return (
<>
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
<ScrollView
horizontal
className="px-4 mb-2"
showsHorizontalScrollIndicator={false}
>
{data && data?.length > 0
? data.map((item) => renderItem(item))
: items && items?.length > 0
? items.map((i) => renderItem(i))
: undefined}
</ScrollView>
</>
);
};

View File

@@ -12,6 +12,7 @@ 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";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -21,6 +22,7 @@ interface Props extends ViewProps {
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
const segments = useSegments();
const { t } = useTranslation();
const from = segments[2];
const destinctPeople = useMemo(() => {
@@ -40,7 +42,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
return (
<View {...props} className="flex flex-col">
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
<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,7 +57,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
}}
className="flex flex-col w-28"
>
<Poster item={i} url={getPrimaryImageUrl({ api, item: i })} />
<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>
</TouchableOpacity>

View File

@@ -8,6 +8,7 @@ import Poster from "../posters/Poster";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -15,10 +16,11 @@ interface Props extends ViewProps {
export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
const { t } = useTranslation();
return (
<View {...props}>
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
<Text className="text-lg font-bold mb-2 px-4">{t("item_card.series")}</Text>
<HorizontalScroll
data={[item]}
height={247}
@@ -29,7 +31,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
className="flex flex-col space-y-2 w-28"
>
<Poster
item={item}
id={item.id}
url={getPrimaryImageUrlById({ api, id: item.ParentId })}
/>
<Text>{item.SeriesName}</Text>

View File

@@ -5,7 +5,7 @@ import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { FlashList } from "@shopify/flash-list";
import { orderBy } from "lodash";
import { Tags } from "@/components/GenreTags";
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import Season from "@/utils/jellyseerr/server/entity/Season";
import {
MediaStatus,
@@ -20,7 +20,9 @@ import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
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";
const JellyseerrSeasonEpisodes: React.FC<{
details: TvDetails;
@@ -61,7 +63,7 @@ const RenderItem = ({ item, index }: any) => {
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
uri: jellyseerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
@@ -101,8 +103,17 @@ const JellyseerrSeasons: React.FC<{
isLoading: boolean;
result?: TvResult;
details?: TvDetails;
hasAdvancedRequest?: boolean,
onAdvancedRequest?: (data: MediaRequestBody) => void;
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
}> = ({ isLoading, result, details, refetch }) => {
}> = ({
isLoading,
result,
details,
refetch,
hasAdvancedRequest,
onAdvancedRequest,
}) => {
if (!details) return null;
const { jellyseerrApi, requestMedia } = useJellyseerr();
@@ -142,7 +153,7 @@ const JellyseerrSeasons: React.FC<{
const requestAll = useCallback(() => {
if (details && jellyseerrApi) {
requestMedia(result?.name!!, {
const body: MediaRequestBody = {
mediaId: details.id,
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
@@ -151,19 +162,25 @@ const JellyseerrSeasons: React.FC<{
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
)
.map((s) => s.seasonNumber),
});
}
if (hasAdvancedRequest) {
return onAdvancedRequest?.(body)
}
requestMedia(result?.name!!, body, refetch);
}
}, [jellyseerrApi, seasons, details]);
}, [jellyseerrApi, seasons, details, hasAdvancedRequest, onAdvancedRequest]);
const promptRequestAll = useCallback(
() =>
Alert.alert("Confirm", "Are you sure you want to request all seasons?", [
Alert.alert(t("jellyseerr.confirm"), t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"), [
{
text: "Cancel",
text: t("jellyseerr.cancel"),
style: "cancel",
},
{
text: "Yes",
text: t("jellyseerr.yes"),
onPress: requestAll,
},
]),
@@ -172,24 +189,26 @@ const JellyseerrSeasons: React.FC<{
const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => {
if (canRequest) {
requestMedia(
`${result?.name!!}, Season ${seasonNumber}`,
{
mediaId: details.id,
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
seasons: [seasonNumber],
},
refetch
)
const body: MediaRequestBody = {
mediaId: details.id,
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
seasons: [seasonNumber],
}
if (hasAdvancedRequest) {
return onAdvancedRequest?.(body)
}
requestMedia(`${result?.name!!}, Season ${seasonNumber}`, body, refetch);
}
}, [requestMedia]);
}, [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">Seasons</Text>
<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} />
@@ -209,7 +228,7 @@ const JellyseerrSeasons: React.FC<{
)}
ListHeaderComponent={() => (
<View className="flex flex-row justify-between items-end px-4">
<Text className="text-lg font-bold mb-2">Seasons</Text>
<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} />
@@ -237,8 +256,8 @@ const JellyseerrSeasons: React.FC<{
<Tags
textClass=""
tags={[
`Season ${season.seasonNumber}`,
`${season.episodeCount} Episodes`,
t("jellyseerr.season_number", {season_number: season.seasonNumber}),
t("jellyseerr.number_episodes", {episode_number: season.episodeCount}),
]}
/>
{[0].map(() => {
@@ -246,7 +265,7 @@ const JellyseerrSeasons: React.FC<{
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
?.status === MediaStatus.UNKNOWN;
return (
<JellyseerrIconStatus
<JellyseerrStatusIcon
key={0}
onPress={() => requestSeason(canRequest, season.seasonNumber)}
className={canRequest ? "bg-gray-700/40" : undefined}

View File

@@ -12,10 +12,12 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
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);
const [api] = useAtom(apiAtom);
const { t } = useTranslation();
const { data: items } = useQuery({
queryKey: ["nextUp", seriesId],
@@ -37,14 +39,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
if (!items?.length)
return (
<View className="px-4">
<Text className="text-lg font-bold mb-2">Next up</Text>
<Text className="opacity-50">No items to display</Text>
<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">Next up</Text>
<Text className="text-lg font-bold px-4 mb-2">{t("item_card.next_up")}</Text>
<FlashList
contentContainerStyle={{ paddingLeft: 16 }}
horizontal

View File

@@ -3,6 +3,7 @@ import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { t } from "i18next";
type Props = {
item: BaseItemDto;
@@ -91,7 +92,7 @@ export const SeasonDropdown: React.FC<Props> = ({
<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">
<Text>Season {seasonIndex}</Text>
<Text>{t("item_card.season")} {seasonIndex}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
@@ -104,7 +105,7 @@ export const SeasonDropdown: React.FC<Props> = ({
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
<DropdownMenu.Label>{t("item_card.seasons")}</DropdownMenu.Label>
{seasons?.sort(sortByIndex).map((season: any) => (
<DropdownMenu.Item
key={season[keys.title]}

View File

@@ -17,7 +17,7 @@ import {
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { useTranslation } from "react-i18next";
type Props = {
item: BaseItemDto;
initialSeasonIndex?: number;
@@ -29,6 +29,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const { t } = useTranslation();
const seasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""],
@@ -145,7 +146,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
/>
{episodes?.length || 0 > 0 ? (
<DownloadItems
title="Download Season"
title={t("item_card.download.download_season")}
className="ml-2"
items={episodes || []}
MissingDownloadIconComponent={() => (
@@ -210,7 +211,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
{(episodes?.length || 0) === 0 ? (
<View className="flex flex-col">
<Text className="text-neutral-500">
No episodes for this season
{t("item_card.no_episodes_for_this_season")}
</Text>
</View>
) : null}

View File

@@ -1,24 +1,45 @@
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import {
Alert,
Linking,
TouchableOpacity,
View,
ViewProps,
} from "react-native";
interface Props extends ViewProps {
item: BaseItemDto;
item: BaseItemDto | MovieDetails | TvDetails;
}
export const ItemActions = ({ item, ...props }: Props) => {
const router = useRouter();
const trailerLink = useMemo(() => {
if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) {
return item.RemoteTrailers[0].Url;
}
const trailerLink = useMemo(() => item.RemoteTrailers?.[0]?.Url, [item]);
if ("relatedVideos" in item) {
return item.relatedVideos?.find((v) => v.type === "Trailer")?.url;
}
return undefined;
}, [item]);
const openTrailer = useCallback(async () => {
if (!trailerLink) return;
if (!trailerLink) {
Alert.alert("No trailer available");
return;
}
const encodedTrailerLink = encodeURIComponent(trailerLink);
router.push(`/trailer/page?url=${encodedTrailerLink}`);
}, [router, trailerLink]);
try {
await Linking.openURL(trailerLink);
} catch (err) {
console.error("Failed to open trailer link:", err);
}
}, [trailerLink]);
return (
<View className="" {...props}>

View File

@@ -0,0 +1,76 @@
import * as DropdownMenu from "zeego/dropdown-menu";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "../common/Text";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import { APP_LANGUAGES } from "@/i18n";
interface Props extends ViewProps {}
export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const { t } = useTranslation();
if (!settings) return null;
return (
<View>
<ListGroup
title={t("home.settings.languages.title")}
>
<ListItem title={t("home.settings.languages.app_language")}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{APP_LANGUAGES.find(
(l) => l.value === settings?.preferedLanguage
)?.label || t("home.settings.languages.system")}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>
{t("home.settings.languages.title")}
</DropdownMenu.Label>
<DropdownMenu.Item
key={"unknown"}
onSelect={() => {
updateSettings({
preferedLanguage: undefined,
});
}}
>
<DropdownMenu.ItemTitle>
{t("home.settings.languages.system")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{APP_LANGUAGES?.map((l) => (
<DropdownMenu.Item
key={l?.value ?? "unknown"}
onSelect={() => {
updateSettings({
preferedLanguage: l.value,
});
}}
>
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
</ListGroup>
</View>
);
};

View File

@@ -3,43 +3,51 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { useMedia } from "./MediaContext";
import { Switch } from "react-native-gesture-handler";
import { useTranslation } from "react-i18next";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons";
import {useSettings} from "@/utils/atoms/settings";
interface Props extends ViewProps {}
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
const media = useMedia();
const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media;
const cultures = media.cultures;
const { t } = useTranslation();
if (!settings) return null;
return (
<View {...props}>
<ListGroup
title={"Audio"}
title={t("home.settings.audio.audio_title")}
description={
<Text className="text-[#8E8D91] text-xs">
Choose a default audio language.
{t("home.settings.audio.audio_hint")}
</Text>
}
>
<ListItem title={"Set Audio Track From Previous Item"}>
<ListItem
title={t("home.settings.audio.set_audio_track")}
disabled={pluginSettings?.rememberAudioSelections?.locked}
>
<Switch
value={settings.rememberAudioSelections}
disabled={pluginSettings?.rememberAudioSelections?.locked}
onValueChange={(value) =>
updateSettings({ rememberAudioSelections: value })
}
/>
</ListItem>
<ListItem title="Audio language">
<ListItem title={t("home.settings.audio.audio_language")}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
<Text className="mr-1 text-[#8E8D91]">
{settings?.defaultAudioLanguage?.DisplayName || "None"}
{settings?.defaultAudioLanguage?.DisplayName || t("home.settings.audio.none")}
</Text>
<Ionicons
name="chevron-expand-sharp"
@@ -57,7 +65,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Label>{t("home.settings.audio.language")}</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-audio"}
onSelect={() => {
@@ -66,7 +74,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
<DropdownMenu.ItemTitle>{t("home.settings.audio.none")}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{cultures?.map((l) => (
<DropdownMenu.Item

View File

@@ -0,0 +1,26 @@
import {View, ViewProps} from "react-native";
import {Text} from "@/components/common/Text";
const DisabledSetting: React.FC<{disabled: boolean, showText?: boolean, text?: string} & ViewProps> = ({
disabled = false,
showText = true,
text,
children,
...props
}) => (
<View
pointerEvents={disabled ? "none" : "auto"}
style={{
opacity: disabled ? 0.5 : 1,
}}
>
<View {...props}>
{disabled && showText &&
<Text className="text-center text-red-700 my-4">{text ?? "Currently disabled by admin."}</Text>
}
{children}
</View>
</View>
)
export default DisabledSetting;

View File

@@ -1,35 +1,49 @@
import { Stepper } from "@/components/inputs/Stepper";
import { useDownload } from "@/providers/DownloadProvider";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { DownloadMethod, Settings, useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React from "react";
import { Switch, TouchableOpacity, View } from "react-native";
import React, { useMemo } from "react";
import { Switch, TouchableOpacity } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
export const DownloadSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const [settings, updateSettings, pluginSettings] = useSettings();
const { setProcesses } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslation();
const allDisabled = useMemo(
() =>
pluginSettings?.downloadMethod?.locked === true &&
pluginSettings?.remuxConcurrentLimit?.locked === true &&
pluginSettings?.autoDownload.locked === true,
[pluginSettings]
);
if (!settings) return null;
return (
<View {...props} className="mb-4">
<ListGroup title="Downloads">
<ListItem title="Download method">
<DisabledSetting disabled={allDisabled} {...props} className="mb-4">
<ListGroup title={t("home.settings.downloads.downloads_title")}>
<ListItem
title={t("home.settings.downloads.download_method")}
disabled={pluginSettings?.downloadMethod?.locked}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings.downloadMethod === "remux"
? "Default"
: "Optimized"}
{settings.downloadMethod === DownloadMethod.Remux
? t("home.settings.downloads.default")
: t("home.settings.downloads.optimized")}
</Text>
<Ionicons
name="chevron-expand-sharp"
@@ -47,33 +61,36 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Methods</DropdownMenu.Label>
<DropdownMenu.Label>{t("home.settings.downloads.methods")}</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ downloadMethod: "remux" });
updateSettings({ downloadMethod: DownloadMethod.Remux });
setProcesses([]);
}}
>
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
<DropdownMenu.ItemTitle>{t("home.settings.downloads.default")}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ downloadMethod: "optimized" });
updateSettings({ downloadMethod: DownloadMethod.Optimized });
setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
<DropdownMenu.ItemTitle>{t("home.settings.downloads.optimized")}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
<ListItem
title="Remux max download"
disabled={settings.downloadMethod !== "remux"}
title={t("home.settings.downloads.remux_max_download")}
disabled={
pluginSettings?.remuxConcurrentLimit?.locked ||
settings.downloadMethod !== DownloadMethod.Remux
}
>
<Stepper
value={settings.remuxConcurrentLimit}
@@ -89,23 +106,32 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
</ListItem>
<ListItem
title="Auto download"
disabled={settings.downloadMethod !== "optimized"}
title={t("home.settings.downloads.auto_download")}
disabled={
pluginSettings?.autoDownload?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
>
<Switch
disabled={settings.downloadMethod !== "optimized"}
disabled={
pluginSettings?.autoDownload?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
value={settings.autoDownload}
onValueChange={(value) => updateSettings({ autoDownload: value })}
/>
</ListItem>
<ListItem
disabled={settings.downloadMethod !== "optimized"}
disabled={
pluginSettings?.optimizedVersionsServerUrl?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
onPress={() => router.push("/settings/optimized-server/page")}
showArrow
title="Optimized Versions Server"
title={t("home.settings.downloads.optimized_versions_server")}
></ListItem>
</ListGroup>
</View>
</DisabledSetting>
);
};

View File

@@ -1,17 +1,17 @@
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { View } from "react-native";
import { Text } from "../common/Text";
import { useCallback, useRef, useState } from "react";
import { Input } from "../common/Input";
import { ListItem } from "../list/ListItem";
import { Loader } from "../Loader";
import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Button } from "../Button";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
import { toast } from "sonner-native";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { useState } from "react";
import { View } from "react-native";
import { toast } from "sonner-native";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const JellyseerrSettings = () => {
const {
@@ -21,8 +21,10 @@ export const JellyseerrSettings = () => {
clearAllJellyseerData,
} = useJellyseerr();
const { t } = useTranslation();
const [user] = useAtom(userAtom);
const [settings, updateSettings] = useSettings();
const [settings, updateSettings, pluginSettings] = useSettings();
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
useState<boolean>(false);
@@ -48,7 +50,7 @@ export const JellyseerrSettings = () => {
updateSettings({ jellyseerrServerUrl });
},
onError: () => {
toast.error("Failed to login");
toast.error(t("jellyseerr.failed_to_login"));
},
onSettled: () => {
setJellyseerrPassword(undefined);
@@ -90,53 +92,50 @@ export const JellyseerrSettings = () => {
<>
<ListGroup title={"Jellyseerr"}>
<ListItem
title="Total media requests"
title={t("home.settings.plugins.jellyseerr.total_media_requests")}
value={jellyseerrUser?.requestCount?.toString()}
/>
<ListItem
title="Movie quota limit"
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
value={
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
jellyseerrUser?.movieQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title="Movie quota days"
title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
value={
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
jellyseerrUser?.movieQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title="TV quota limit"
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
/>
<ListItem
title="TV quota days"
value={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
value={jellyseerrUser?.tvQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
/>
</ListGroup>
<View className="p-4">
<Button color="red" onPress={clearData}>
Reset Jellyseerr config
{t("home.settings.plugins.jellyseerr.reset_jellyseerr_config_button")}
</Button>
</View>
</>
) : (
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
<Text className="text-xs text-red-600 mb-2">
This integration is in its early stages. Expect things to change.
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className="font-bold mb-1">Server URL</Text>
<Text className="font-bold mb-1">{t("home.settings.plugins.jellyseerr.server_url")}</Text>
<View className="flex flex-col shrink mb-2">
<Text className="text-xs text-gray-600">
Example: http(s)://your-host.url
</Text>
<Text className="text-xs text-gray-600">
(add port if required)
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
</View>
<Input
placeholder="Jellyseerr URL..."
placeholder={t("home.settings.plugins.jellyseerr.server_url_placeholder")}
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
@@ -166,7 +165,7 @@ export const JellyseerrSettings = () => {
marginBottom: 8,
}}
>
{promptForJellyseerrPass ? "Clear" : "Save"}
{promptForJellyseerrPass ? t("home.settings.plugins.jellyseerr.clear_button") : t("home.settings.plugins.jellyseerr.save_button")}
</Button>
<View
@@ -175,11 +174,11 @@ export const JellyseerrSettings = () => {
opacity: promptForJellyseerrPass ? 1 : 0.5,
}}
>
<Text className="font-bold mb-2">Password</Text>
<Text className="font-bold mb-2">{t("home.settings.plugins.jellyseerr.password")}</Text>
<Input
autoFocus={true}
focusable={true}
placeholder={`Enter password for Jellyfin user ${user?.Name}`}
placeholder={t("home.settings.plugins.jellyseerr.password_placeholder", {username: user?.Name})}
value={jellyseerrPassword}
keyboardType="default"
secureTextEntry={true}
@@ -199,7 +198,7 @@ export const JellyseerrSettings = () => {
className="h-12 mt-2"
onPress={() => loginToJellyseerrMutation.mutate()}
>
Login
{t("home.settings.plugins.jellyseerr.login_button")}
</Button>
</View>
</View>

View File

@@ -1,72 +1,64 @@
import React from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import React, {useMemo} from "react";
import { ViewProps } from "react-native";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Text } from "../common/Text";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
import {Stepper} from "@/components/inputs/Stepper";
interface Props extends ViewProps {}
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings();
if (!settings) return null;
const renderSkipControl = (
value: number,
onDecrease: () => void,
onIncrease: () => void
) => (
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={onDecrease}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{value}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={onIncrease}
>
<Text>+</Text>
</TouchableOpacity>
</View>
);
const disabled = useMemo(() => (
pluginSettings?.forwardSkipTime?.locked === true &&
pluginSettings?.rewindSkipTime?.locked === true
),
[pluginSettings]
)
return (
<View {...props}>
<ListGroup title="Media Controls">
<ListItem title="Forward Skip Length">
{renderSkipControl(
settings.forwardSkipTime,
() =>
updateSettings({
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
}),
() =>
updateSettings({
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
})
)}
<DisabledSetting
disabled={disabled}
{...props}
>
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
<ListItem
title={t("home.settings.media_controls.forward_skip_length")}
disabled={pluginSettings?.forwardSkipTime?.locked}
>
<Stepper
value={settings.forwardSkipTime}
disabled={pluginSettings?.forwardSkipTime?.locked}
step={5}
appendValue={t("home.settings.media_controls.seconds_unit")}
min={0}
max={60}
onUpdate={(forwardSkipTime) => updateSettings({forwardSkipTime})}
/>
</ListItem>
<ListItem title="Rewind Length">
{renderSkipControl(
settings.rewindSkipTime,
() =>
updateSettings({
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
}),
() =>
updateSettings({
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
})
)}
<ListItem
title={t("home.settings.media_controls.rewind_length")}
disabled={pluginSettings?.rewindSkipTime?.locked}
>
<Stepper
value={settings.rewindSkipTime}
disabled={pluginSettings?.rewindSkipTime?.locked}
step={5}
appendValue={t("home.settings.media_controls.seconds_unit")}
min={0}
max={60}
onUpdate={(rewindSkipTime) => updateSettings({rewindSkipTime})}
/>
</ListItem>
</ListGroup>
</View>
</DisabledSetting>
);
};

View File

@@ -1,5 +1,6 @@
import { TextInput, View, Linking } from "react-native";
import { Text } from "../common/Text";
import { useTranslation } from "react-i18next";
interface Props {
value: string;
@@ -14,14 +15,16 @@ export const OptimizedServerForm: React.FC<Props> = ({
Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
};
const { t } = useTranslation();
return (
<View>
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
<View className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}>
<Text className="mr-4">URL</Text>
<Text className="mr-4">{t("home.settings.downloads.url")}</Text>
<TextInput
className="text-white"
placeholder="http(s)://domain.org:port"
placeholder={t("home.settings.downloads.server_url_placeholder")}
value={value}
keyboardType="url"
returnKeyType="done"
@@ -32,10 +35,9 @@ export const OptimizedServerForm: React.FC<Props> = ({
</View>
</View>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Enter the URL for the optimize server. The URL should include http or
https and optionally the port.{" "}
{t("home.settings.downloads.optimized_version_hint")}{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
Read more about the optimize server.
{t("home.settings.downloads.read_more_about_optimized_server")}
</Text>
</Text>
</View>

View File

@@ -6,20 +6,24 @@ import {
} from "@/utils/background-tasks";
import { Ionicons } from "@expo/vector-icons";
import * as BackgroundFetch from "expo-background-fetch";
import { useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import React, { useEffect } from "react";
import { Linking, Switch, TouchableOpacity, ViewProps } from "react-native";
import React, { useEffect, useMemo } from "react";
import { Linking, Switch, TouchableOpacity } from "react-native";
import { toast } from "sonner-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {}
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
import Dropdown from "@/components/common/Dropdown";
export const OtherSettings: React.FC = () => {
const [settings, updateSettings] = useSettings();
const router = useRouter();
const [settings, updateSettings, pluginSettings] = useSettings();
const { t } = useTranslation();
/********************
* Background task
@@ -51,133 +55,122 @@ export const OtherSettings: React.FC = () => {
/**********************
*********************/
const disabled = useMemo(
() =>
pluginSettings?.autoRotate?.locked === true &&
pluginSettings?.defaultVideoOrientation?.locked === true &&
pluginSettings?.safeAreaInControlsEnabled?.locked === true &&
pluginSettings?.showCustomMenuLinks?.locked === true &&
pluginSettings?.hiddenLibraries?.locked === true &&
pluginSettings?.disableHapticFeedback?.locked === true,
[pluginSettings]
);
const orientations = [
ScreenOrientation.OrientationLock.DEFAULT,
ScreenOrientation.OrientationLock.PORTRAIT_UP,
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
];
if (!settings) return null;
return (
<ListGroup title="Other" className="mb-4">
<ListItem title="Auto rotate">
<Switch
value={settings.autoRotate}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</ListItem>
<DisabledSetting disabled={disabled}>
<ListGroup title={t("home.settings.other.other_title")} className="">
<ListItem
title={t("home.settings.other.auto_rotate")}
disabled={pluginSettings?.autoRotate?.locked}
>
<Switch
value={settings.autoRotate}
disabled={pluginSettings?.autoRotate?.locked}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</ListItem>
<ListItem title="Video orientation" disabled={settings.autoRotate}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
</Text>
<Ionicons name="chevron-expand-sharp" size={18} color="#5A5960" />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.DEFAULT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.DEFAULT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.PORTRAIT_UP,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.PORTRAIT_UP
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="4"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
<ListItem title="Safe area in controls">
<Switch
value={settings.safeAreaInControlsEnabled}
onValueChange={(value) =>
updateSettings({ safeAreaInControlsEnabled: value })
<ListItem
title={t("home.settings.other.video_orientation")}
disabled={
pluginSettings?.defaultVideoOrientation?.locked ||
settings.autoRotate
}
/>
</ListItem>
>
<Dropdown
data={orientations}
disabled={
pluginSettings?.defaultVideoOrientation?.locked ||
settings.autoRotate
}
keyExtractor={String}
titleExtractor={(item) => ScreenOrientationEnum[item]}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{t(ScreenOrientationEnum[settings.defaultVideoOrientation])}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
}
label={t("home.settings.other.orientation")}
onSelected={(defaultVideoOrientation) =>
updateSettings({ defaultVideoOrientation })
}
/>
</ListItem>
<ListItem
title="Show Custom Menu Links"
onPress={() =>
Linking.openURL(
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
)
}
>
<Switch
value={settings.showCustomMenuLinks}
onValueChange={(value) =>
updateSettings({ showCustomMenuLinks: value })
<ListItem
title={t("home.settings.other.safe_area_in_controls")}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
>
<Switch
value={settings.safeAreaInControlsEnabled}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
onValueChange={(value) =>
updateSettings({ safeAreaInControlsEnabled: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.other.show_custom_menu_links")}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onPress={() =>
Linking.openURL(
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
)
}
>
<Switch
value={settings.showCustomMenuLinks}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onValueChange={(value) =>
updateSettings({ showCustomMenuLinks: value })
}
/>
</ListItem>
<ListItem
onPress={() => router.push("/settings/hide-libraries/page")}
title={t("home.settings.other.hide_libraries")}
showArrow
/>
</ListItem>
</ListGroup>
<ListItem
title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked}
>
<Switch
value={settings.disableHapticFeedback}
disabled={pluginSettings?.disableHapticFeedback?.locked}
onValueChange={(disableHapticFeedback) =>
updateSettings({ disableHapticFeedback })
}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);
};

View File

@@ -4,16 +4,19 @@ import React from "react";
import { View } from "react-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
export const PluginSettings = () => {
const [settings, updateSettings] = useSettings();
const router = useRouter();
const { t } = useTranslation();
if (!settings) return null;
return (
<View>
<ListGroup title="Plugins">
<ListGroup title={t("home.settings.plugins.plugins_title")} className="mb-4">
<ListItem
onPress={() => router.push("/settings/jellyseerr/page")}
title={"Jellyseerr"}
@@ -24,11 +27,6 @@ export const PluginSettings = () => {
title="Marlin Search"
showArrow
/>
<ListItem
onPress={() => router.push("/settings/popular-lists/page")}
title="Popular Lists"
showArrow
/>
</ListGroup>
</View>
);

View File

@@ -1,59 +1,118 @@
import { Alert, View, ViewProps } from "react-native";
import { Text } from "../common/Text";
import { ListItem } from "../list/ListItem";
import { Button } from "../Button";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
import Constants from "expo-constants";
import Application from "expo-application";
import { ListGroup } from "../list/ListGroup";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
import { useAtom } from "jotai";
import React, { useCallback, useRef, useState } from "react";
import { Alert, View, ViewProps } from "react-native";
import { Button } from "../Button";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {}
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [quickConnectCode, setQuickConnectCode] = useState<string>();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const openQuickConnectAuthCodeInput = () => {
Alert.prompt(
"Quick connect",
"Enter the quick connect code",
async (text) => {
if (text) {
try {
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
code: text,
userId: user?.Id,
});
if (res.status === 200) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
Alert.alert("Success", "Quick connect authorized");
} else {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Invalid code");
}
} catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Invalid code");
}
const { t } = useTranslation();
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
const authorizeQuickConnect = useCallback(async () => {
if (quickConnectCode) {
try {
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
code: quickConnectCode,
userId: user?.Id,
});
if (res.status === 200) {
successHapticFeedback();
Alert.alert(t("home.settings.quick_connect.success"), t("home.settings.quick_connect.quick_connect_autorized"));
setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close();
} else {
errorHapticFeedback();
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
}
} catch (e) {
errorHapticFeedback();
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
}
);
};
}
}, [api, user, quickConnectCode]);
return (
<View {...props}>
<ListGroup title={"Quick Connect"}>
<ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
<ListItem
onPress={openQuickConnectAuthCodeInput}
title="Authorize Quick Connect"
onPress={() => bottomSheetModalRef?.current?.present()}
title={t("home.settings.quick_connect.authorize_button")}
textColor="blue"
></ListItem>
/>
</ListGroup>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
>
<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("home.settings.quick_connect.quick_connect_title")}
</Text>
</View>
<View className="flex flex-col space-y-2">
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
<BottomSheetTextInput
style={{ color: "white" }}
clearButtonMode="always"
placeholder={t("home.settings.quick_connect.enter_the_quick_connect_code")}
placeholderTextColor="#9CA3AF"
value={quickConnectCode}
onChangeText={setQuickConnectCode}
/>
</View>
</View>
<Button
className="mt-auto"
onPress={authorizeQuickConnect}
color="purple"
>
{t("home.settings.quick_connect.authorize")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</View>
);
};

View File

@@ -1,18 +1,19 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider";
import { clearLogs } from "@/utils/log";
import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import * as Haptics from "expo-haptics";
import { View } from "react-native";
import * as Progress from "react-native-progress";
import { toast } from "sonner-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
const { t } = useTranslation();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const { data: size, isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize", appSizeUsage],
@@ -29,10 +30,10 @@ export const StorageSettings = () => {
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
successHapticFeedback();
} catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
toast.error("Error deleting files");
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
};
@@ -44,11 +45,10 @@ export const StorageSettings = () => {
<View>
<View className="flex flex-col gap-y-1">
<View className="flex flex-row items-center justify-between">
<Text className="">Storage</Text>
<Text className="">{t("home.settings.storage.storage_title")}</Text>
{size && (
<Text className="text-neutral-500">
{Number(size.total - size.remaining).bytesToReadable()} of{" "}
{size.total?.bytesToReadable()} used
{t("home.settings.storage.size_used", {used: Number(size.total - size.remaining).bytesToReadable(), total: size.total?.bytesToReadable()})}
</Text>
)}
</View>
@@ -79,18 +79,13 @@ export const StorageSettings = () => {
<View className="flex flex-row items-center">
<View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View>
<Text className="text-white text-xs">
App {calculatePercentage(size.app, size.total)}%
{t("home.settings.storage.app_usage", {usedSpace: calculatePercentage(size.app, size.total)})}
</Text>
</View>
<View className="flex flex-row items-center">
<View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View>
<Text className="text-white text-xs">
Phone{" "}
{calculatePercentage(
size.total - size.remaining - size.app,
size.total
)}
%
{t("home.settings.storage.phone_usage", {availableSpace: calculatePercentage(size.total - size.remaining - size.app, size.total)})}
</Text>
</View>
</>
@@ -101,7 +96,7 @@ export const StorageSettings = () => {
<ListItem
textColor="red"
onPress={onDeleteClicked}
title="Delete All Downloaded Files"
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
</View>

View File

@@ -7,13 +7,19 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useTranslation } from "react-i18next";
import {useSettings} from "@/utils/atoms/settings";
import {Stepper} from "@/components/inputs/Stepper";
import Dropdown from "@/components/common/Dropdown";
interface Props extends ViewProps {}
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const media = useMedia();
const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media;
const cultures = media.cultures;
const { t } = useTranslation();
if (!settings) return null;
@@ -25,22 +31,33 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
SubtitlePlaybackMode.None,
];
const subtitleModeKeys = {
[SubtitlePlaybackMode.Default]: "home.settings.subtitles.modes.Default",
[SubtitlePlaybackMode.Smart]: "home.settings.subtitles.modes.Smart",
[SubtitlePlaybackMode.OnlyForced]: "home.settings.subtitles.modes.OnlyForced",
[SubtitlePlaybackMode.Always]: "home.settings.subtitles.modes.Always",
[SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None",
};
return (
<View {...props}>
<ListGroup
title={"Subtitles"}
title={t("home.settings.subtitles.subtitle_title")}
description={
<Text className="text-[#8E8D91] text-xs">
Configure subtitle preferences.
{t("home.settings.subtitles.subtitle_hint")}
</Text>
}
>
<ListItem title="Subtitle language">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
<Dropdown
data={[{DisplayName: t("home.settings.subtitles.none"), ThreeLetterISOLanguageName: "none-subs" },...(cultures ?? [])]}
keyExtractor={(item) => item?.ThreeLetterISOLanguageName ?? "unknown"}
titleExtractor={(item) => item?.DisplayName}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
{settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")}
</Text>
<Ionicons
name="chevron-expand-sharp"
@@ -48,51 +65,31 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
color="#5A5960"
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-subs"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{cultures?.map((l) => (
<DropdownMenu.Item
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>
{l.DisplayName}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
}
label={t("home.settings.subtitles.language")}
onSelected={(defaultSubtitleLanguage) =>
updateSettings({
defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === t("home.settings.subtitles.none")
? null
: defaultSubtitleLanguage
})
}
/>
</ListItem>
<ListItem title="Subtitle Mode">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<ListItem
title={t("home.settings.subtitles.subtitle_mode")}
disabled={pluginSettings?.subtitleMode?.locked}
>
<Dropdown
data={subtitleModes}
disabled={pluginSettings?.subtitleMode?.locked}
keyExtractor={String}
titleExtractor={(item) => t(subtitleModeKeys[item]) || String(item)}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings?.subtitleMode || "Loading"}
{t(subtitleModeKeys[settings?.subtitleMode]) || t("home.settings.subtitles.loading")}
</Text>
<Ionicons
name="chevron-expand-sharp"
@@ -100,68 +97,39 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
color="#5A5960"
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitle Mode</DropdownMenu.Label>
{subtitleModes?.map((l) => (
<DropdownMenu.Item
key={l}
onSelect={() => {
updateSettings({
subtitleMode: l,
});
}}
>
<DropdownMenu.ItemTitle>{l}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
}
label={t("home.settings.subtitles.subtitle_mode")}
onSelected={(subtitleMode) =>
updateSettings({subtitleMode})
}
/>
</ListItem>
<ListItem title="Set Subtitle Track From Previous Item">
<ListItem
title={t("home.settings.subtitles.set_subtitle_track")}
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
>
<Switch
value={settings.rememberSubtitleSelections}
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
onValueChange={(value) =>
updateSettings({ rememberSubtitleSelections: value })
}
/>
</ListItem>
<ListItem title="Subtitle Size">
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
subtitleSize: Math.max(0, settings.subtitleSize - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 px-3 py-2 flex items-center justify-center">
{settings.subtitleSize}
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
subtitleSize: Math.min(120, settings.subtitleSize + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
<ListItem
title={t("home.settings.subtitles.subtitle_size")}
disabled={pluginSettings?.subtitleSize?.locked}
>
<Stepper
value={settings.subtitleSize}
disabled={pluginSettings?.subtitleSize?.locked}
step={5}
min={0}
max={120}
onUpdate={(subtitleSize) => updateSettings({subtitleSize})}
/>
</ListItem>
</ListGroup>
</View>

View File

@@ -7,12 +7,14 @@ import { useAtom } from "jotai";
import Constants from "expo-constants";
import Application from "expo-application";
import { ListGroup } from "../list/ListGroup";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps {}
export const UserInfo: React.FC<Props> = ({ ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const version =
Application?.nativeApplicationVersion ||
@@ -21,11 +23,11 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
return (
<View {...props}>
<ListGroup title={"User Info"}>
<ListItem title="User" value={user?.Name} />
<ListItem title="Server" value={api?.basePath} />
<ListItem title="Token" value={api?.accessToken} />
<ListItem title="App version" value={version} />
<ListGroup title={t("home.settings.user_info.user_info_title")}>
<ListItem title={t("home.settings.user_info.user")} value={user?.Name} />
<ListItem title={t("home.settings.user_info.server")} value={api?.basePath} />
<ListItem title={t("home.settings.user_info.token")} value={api?.accessToken} />
<ListItem title={t("home.settings.user_info.app_version")} value={version} />
</ListGroup>
</View>
);

View File

@@ -17,14 +17,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerLeft: () => <HeaderBackButton />,
};
const routes = [
"actors/[actorId]",
"albums/[albumId]",
"artists/index",
"artists/[artistId]",
"items/page",
"series/[id]",
];
const routes = ["actors/[actorId]", "items/page", "series/[id]"];
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));

View File

@@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import {
@@ -29,12 +29,13 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import { debounce } from "lodash";
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { TouchableOpacity, useWindowDimensions, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
@@ -42,10 +43,7 @@ import {
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import {
SafeAreaView,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { VideoRef } from "react-native-video";
import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
@@ -56,6 +54,8 @@ import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { useControlsTimeout } from "./useControlsTimeout";
import { VideoTouchOverlay } from "./VideoTouchOverlay";
interface Props {
item: BaseItemDto;
@@ -86,6 +86,8 @@ interface Props {
isVlc?: boolean;
}
const CONTROLS_TIMEOUT = 4000;
export const Controls: React.FC<Props> = ({
item,
seek,
@@ -118,6 +120,13 @@ export const Controls: React.FC<Props> = ({
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [episodeView, setEpisodeView] = useState(false);
const [isSliding, setIsSliding] = useState(false);
// Used when user changes audio through audio button on device.
const [showAudioSlider, setShowAudioSlider] = useState(false);
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const { previousItem, nextItem } = useAdjacentItems({ item });
const {
trickPlayUrl,
@@ -135,6 +144,23 @@ export const Controls: React.FC<Props> = ({
const wasPlayingRef = useRef(false);
const lastProgressRef = useRef<number>(0);
const lightHapticFeedback = useHaptic("light");
useEffect(() => {
prefetchAllTrickplayImages();
}, []);
useEffect(() => {
if (item) {
progress.value = isVlc
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
: item?.UserData?.PlaybackPositionTicks || 0;
max.value = isVlc
? ticksToMs(item.RunTimeTicks || 0)
: item.RunTimeTicks || 0;
}
}, [item, isVlc]);
const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
bitrateValue: string;
audioIndex: string;
@@ -160,7 +186,7 @@ export const Controls: React.FC<Props> = ({
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
@@ -198,7 +224,7 @@ export const Controls: React.FC<Props> = ({
const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
@@ -260,20 +286,19 @@ export const Controls: React.FC<Props> = ({
[updateTimes]
);
useEffect(() => {
if (item) {
progress.value = isVlc
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
: item?.UserData?.PlaybackPositionTicks || 0;
max.value = isVlc
? ticksToMs(item.RunTimeTicks || 0)
: item.RunTimeTicks || 0;
}
}, [item, isVlc]);
useEffect(() => {
prefetchAllTrickplayImages();
const hideControls = useCallback(() => {
setShowControls(false);
setShowAudioSlider(false);
}, []);
const { handleControlsInteraction } = useControlsTimeout({
showControls,
isSliding,
episodeView,
onHideControls: hideControls,
timeout: CONTROLS_TIMEOUT,
});
const toggleControls = () => {
if (showControls) {
setShowAudioSlider(false);
@@ -294,16 +319,13 @@ export const Controls: React.FC<Props> = ({
isSeeking.value = true;
}, [showControls, isPlaying]);
const [isSliding, setIsSliding] = useState(false);
const handleSliderComplete = useCallback(
async (value: number) => {
isSeeking.value = false;
progress.value = value;
setIsSliding(false);
await seek(
Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
);
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
if (wasPlayingRef.current === true) play();
},
[isVlc]
@@ -326,14 +348,14 @@ export const Controls: React.FC<Props> = ({
const handleSkipBackward = useCallback(async () => {
if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
await seek(newTime);
seek(newTime);
if (wasPlayingRef.current === true) play();
}
} catch (error) {
@@ -344,14 +366,14 @@ export const Controls: React.FC<Props> = ({
const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(settings.forwardSkipTime)
: ticksToSeconds(curr) + settings.forwardSkipTime;
await seek(Math.max(0, newTime));
seek(Math.max(0, newTime));
if (wasPlayingRef.current === true) play();
}
} catch (error) {
@@ -359,11 +381,62 @@ export const Controls: React.FC<Props> = ({
}
}, [settings, isPlaying, isVlc]);
const goToItem = useCallback(
async (itemId: string) => {
try {
const gotoItem = await getItemById(api, itemId);
if (!settings || !gotoItem) return;
lightHapticFeedback();
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
gotoItem,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: gotoItem.Id ?? "", // Ensure itemId is a string
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue.toString(),
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
} catch (error) {
console.error("Error in gotoEpisode:", error);
}
},
[settings, subtitleIndex, audioIndex]
);
const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
}, []);
const switchOnEpisodeMode = useCallback(() => {
setEpisodeView(true);
if (isPlaying) togglePlay();
}, [isPlaying, togglePlay]);
const memoizedRenderBubble = useCallback(() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
@@ -376,12 +449,11 @@ export const Controls: React.FC<Props> = ({
<View
style={{
position: "absolute",
left: -57,
bottom: 15,
left: -62,
bottom: 0,
paddingTop: 30,
paddingBottom: 5,
width: tileWidth * 1.5,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "center",
alignItems: "center",
}}
@@ -427,69 +499,13 @@ export const Controls: React.FC<Props> = ({
);
}, [trickPlayUrl, trickplayInfo, time]);
const [EpisodeView, setEpisodeView] = useState(false);
const switchOnEpisodeMode = () => {
setEpisodeView(true);
if (isPlaying) togglePlay();
};
const goToItem = useCallback(
async (itemId: string) => {
try {
const gotoItem = await getItemById(api, itemId);
if (!settings || !gotoItem) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
gotoItem,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: gotoItem.Id ?? "", // Ensure itemId is a string
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue.toString(),
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
} catch (error) {
console.error("Error in gotoEpisode:", error);
}
},
[settings, subtitleIndex, audioIndex]
);
// Used when user changes audio through audio button on device.
const [showAudioSlider, setShowAudioSlider] = useState(false);
return (
<ControlProvider
item={item}
mediaSource={mediaSource}
isVideoLoaded={isVideoLoaded}
>
{EpisodeView ? (
{episodeView ? (
<EpisodeList
item={item}
close={() => setEpisodeView(false)}
@@ -497,88 +513,76 @@ export const Controls: React.FC<Props> = ({
/>
) : (
<>
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
<View
style={[
{
position: "absolute",
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
opacity: showControls ? 1 : 0,
zIndex: 1000,
},
]}
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
>
{!mediaSource?.TranscodingUrl ? (
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
</View>
</VideoProvider>
<Pressable
onPressIn={() => {
toggleControls();
}}
style={{
position: "absolute",
width: Dimensions.get("window").width,
height: Dimensions.get("window").height,
}}
></Pressable>
<VideoTouchOverlay
screenWidth={screenWidth}
screenHeight={screenHeight}
showControls={showControls}
onToggleControls={toggleControls}
/>
<View
style={[
{
position: "absolute",
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
width: settings?.safeAreaInControlsEnabled
? screenWidth - insets.left - insets.right
: screenWidth,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
className={`flex flex-row w-full pt-2`}
>
{item?.Type === "Episode" && !offline && (
<TouchableOpacity
onPress={() => {
switchOnEpisodeMode();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
<View className="mr-auto">
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
<Ionicons name="list" size={24} color="white" />
</TouchableOpacity>
)}
{previousItem && !offline && (
<TouchableOpacity
onPress={goToPreviousItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
)}
{!mediaSource?.TranscodingUrl ? (
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
</VideoProvider>
</View>
{nextItem && !offline && (
<TouchableOpacity
onPress={goToNextItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
)}
<View className="flex flex-row items-center space-x-2 ">
{item?.Type === "Episode" && !offline && (
<TouchableOpacity
onPress={() => {
switchOnEpisodeMode();
}}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<Ionicons name="list" size={24} color="white" />
</TouchableOpacity>
)}
{previousItem && !offline && (
<TouchableOpacity
onPress={goToPreviousItem}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
)}
{mediaSource?.TranscodingUrl && (
{nextItem && !offline && (
<TouchableOpacity
onPress={goToNextItem}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
)}
{/* {mediaSource?.TranscodingUrl && ( */}
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
@@ -586,16 +590,20 @@ export const Controls: React.FC<Props> = ({
color="white"
/>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
{/* )} */}
<TouchableOpacity
onPress={async () => {
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
router.back();
}}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
</View>
<View
@@ -721,10 +729,11 @@ export const Controls: React.FC<Props> = ({
bottom: settings?.safeAreaInControlsEnabled ? insets.bottom : 0,
},
]}
className={`flex flex-col p-4`}
className={`flex flex-col px-2`}
onTouchStart={handleControlsInteraction}
>
<View
className="shrink flex flex-col justify-center h-full mb-2"
className="shrink flex flex-col justify-center h-full"
style={{
flexDirection: "row",
justifyContent: "space-between",
@@ -738,10 +747,12 @@ export const Controls: React.FC<Props> = ({
}}
pointerEvents={showControls ? "box-none" : "none"}
>
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text>
<Text className="opacity-50">
{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}
</Text>
)}
<Text className="font-bold text-xl">{item?.Name}</Text>
{item?.Type === "Movie" && (
<Text className="text-xs opacity-50">
{item?.ProductionYear}
@@ -776,7 +787,7 @@ export const Controls: React.FC<Props> = ({
</View>
</View>
<View
className={`flex flex-col-reverse py-4 pb-1 px-4 rounded-lg items-center bg-neutral-800`}
className={`flex flex-col-reverse rounded-lg items-center my-2`}
style={{
opacity: showControls ? 1 : 0,
}}
@@ -792,19 +803,7 @@ export const Controls: React.FC<Props> = ({
bubbleTextColor: "#666",
heartbeatColor: "#999",
}}
renderThumb={() => (
<View
style={{
width: 18,
height: 18,
left: -2,
borderRadius: 10,
backgroundColor: "#fff",
justifyContent: "center",
alignItems: "center",
}}
/>
)}
renderThumb={() => null}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
@@ -819,7 +818,7 @@ export const Controls: React.FC<Props> = ({
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<View className="flex flex-row items-center justify-between mt-2">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
</Text>

View File

@@ -1,26 +1,26 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToSeconds } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState, useRef } from "react";
import { View, TouchableOpacity } from "react-native";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import { Loader } from "@/components/Loader";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Text } from "@/components/common/Text";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
HorizontalScroll,
HorizontalScrollRef,
} from "@/components/common/HorrizontalScroll";
import { Text } from "@/components/common/Text";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { Loader } from "@/components/Loader";
import {
SeasonDropdown,
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 { Ionicons } from "@expo/vector-icons";
import { 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, useRef, useState } from "react";
import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type Props = {
item: BaseItemDto;

View File

@@ -9,6 +9,7 @@ import Animated, {
runOnJS,
} from "react-native-reanimated";
import { Colors } from "@/constants/Colors";
import { useTranslation } from "react-i18next";
interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps {
onFinish?: () => void;
@@ -63,6 +64,8 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
return null;
}
const { t } = useTranslation();
return (
<TouchableOpacity
className="w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900"
@@ -71,7 +74,7 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
>
<Animated.View style={animatedStyle} />
<View className="px-3 py-3">
<Text className="text-center font-bold">Next Episode</Text>
<Text className="text-center font-bold">{t("player.next_episode")}</Text>
</View>
</TouchableOpacity>
);

View File

@@ -0,0 +1,38 @@
import { Pressable } from "react-native";
import { useTapDetection } from "./useTapDetection";
interface Props {
screenWidth: number;
screenHeight: number;
showControls: boolean;
onToggleControls: () => void;
}
export const VideoTouchOverlay = ({
screenWidth,
screenHeight,
showControls,
onToggleControls,
}: Props) => {
const { handleTouchStart, handleTouchEnd } = useTapDetection({
onValidTap: onToggleControls,
});
return (
<Pressable
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
style={{
position: "absolute",
width: screenWidth,
height: screenHeight,
backgroundColor: "black",
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: showControls ? 0.75 : 0,
}}
/>
);
};

View File

@@ -74,7 +74,7 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>

View File

@@ -118,17 +118,10 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
);
return (
<View
style={{
position: "absolute",
zIndex: 1000,
opacity: showControls ? 1 : 0,
}}
className="p-4"
>
<View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>

View File

@@ -0,0 +1,56 @@
import { useEffect, useRef } from "react";
interface UseControlsTimeoutProps {
showControls: boolean;
isSliding: boolean;
episodeView: boolean;
onHideControls: () => void;
timeout?: number;
}
export const useControlsTimeout = ({
showControls,
isSliding,
episodeView,
onHideControls,
timeout = 4000,
}: UseControlsTimeoutProps) => {
const controlsTimeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
const resetControlsTimeout = () => {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
if (showControls && !isSliding && !episodeView) {
controlsTimeoutRef.current = setTimeout(() => {
onHideControls();
}, timeout);
}
};
resetControlsTimeout();
return () => {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
};
}, [showControls, isSliding, episodeView, timeout, onHideControls]);
const handleControlsInteraction = () => {
if (showControls) {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
controlsTimeoutRef.current = setTimeout(() => {
onHideControls();
}, timeout);
}
};
return {
handleControlsInteraction,
};
};

View File

@@ -0,0 +1,48 @@
import { useRef } from "react";
import { GestureResponderEvent } from "react-native";
interface TapDetectionOptions {
maxDuration?: number;
maxDistance?: number;
onValidTap?: () => void;
}
export const useTapDetection = ({
maxDuration = 200,
maxDistance = 10,
onValidTap,
}: TapDetectionOptions = {}) => {
const touchStartTime = useRef(0);
const touchStartPosition = useRef({ x: 0, y: 0 });
const handleTouchStart = (event: GestureResponderEvent) => {
touchStartTime.current = Date.now();
touchStartPosition.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
};
};
const handleTouchEnd = (event: GestureResponderEvent) => {
const touchEndTime = Date.now();
const touchEndPosition = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
};
const touchDuration = touchEndTime - touchStartTime.current;
const touchDistance = Math.sqrt(
Math.pow(touchEndPosition.x - touchStartPosition.current.x, 2) +
Math.pow(touchEndPosition.y - touchStartPosition.current.y, 2)
);
if (touchDuration < maxDuration && touchDistance < maxDistance) {
onValidTap?.();
}
};
return {
handleTouchStart,
handleTouchEnd,
};
};

View File

@@ -6,6 +6,7 @@ import React, { useEffect, useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "../common/Text";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps {
playerRef: React.RefObject<VlcPlayerViewRef>;
@@ -32,6 +33,8 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
return (
<View
style={{
@@ -42,19 +45,19 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
}}
{...props}
>
<Text className="font-bold">Playback State:</Text>
<Text className="font-bold mt-2.5">Audio Tracks:</Text>
<Text className="font-bold">{t("player.playback_state")}</Text>
<Text className="font-bold mt-2.5">{t("player.audio_tracks")}</Text>
{audioTracks &&
audioTracks.map((track, index) => (
<Text key={index}>
{track.name} (Index: {track.index})
{track.name} ({t("player.index")} {track.index})
</Text>
))}
<Text className="font-bold mt-2.5">Subtitle Tracks:</Text>
<Text className="font-bold mt-2.5">{t("player.subtitles_tracks")}</Text>
{subtitleTracks &&
subtitleTracks.map((track, index) => (
<Text key={index}>
{track.name} (Index: {track.index})
{track.name} ({t("player.index")} {track.index})
</Text>
))}
<TouchableOpacity
@@ -66,7 +69,7 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
}
}}
>
<Text className="text-white text-center">Refresh Tracks</Text>
<Text className="text-white text-center">{t("player.refresh_tracks")}</Text>
</TouchableOpacity>
</View>
);