mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-14 08:12:22 +00:00
Merge branch 'develop' into chore/expo-52
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={`
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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={() => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
46
components/JellyfinServerDiscovery.tsx
Normal file
46
components/JellyfinServerDiscovery.tsx
Normal 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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?.();
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
108
components/common/Dropdown.tsx
Normal file
108
components/common/Dropdown.tsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)();
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
41
components/jellyseerr/Cast.tsx
Normal file
41
components/jellyseerr/Cast.tsx
Normal 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;
|
||||
220
components/jellyseerr/DetailFacts.tsx
Normal file
220
components/jellyseerr/DetailFacts.tsx
Normal 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;
|
||||
161
components/jellyseerr/JellyseerrIndexPage.tsx
Normal file
161
components/jellyseerr/JellyseerrIndexPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
components/jellyseerr/JellyseerrMediaIcon.tsx
Normal file
37
components/jellyseerr/JellyseerrMediaIcon.tsx
Normal 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;
|
||||
@@ -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;
|
||||
160
components/jellyseerr/ParallaxSlideShow.tsx
Normal file
160
components/jellyseerr/ParallaxSlideShow.tsx
Normal 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;
|
||||
42
components/jellyseerr/PersonPoster.tsx
Normal file
42
components/jellyseerr/PersonPoster.tsx
Normal 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;
|
||||
236
components/jellyseerr/RequestModal.tsx
Normal file
236
components/jellyseerr/RequestModal.tsx
Normal 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;
|
||||
51
components/jellyseerr/discover/CompanySlide.tsx
Normal file
51
components/jellyseerr/discover/CompanySlide.tsx
Normal 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;
|
||||
47
components/jellyseerr/discover/Discover.tsx
Normal file
47
components/jellyseerr/discover/Discover.tsx
Normal 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;
|
||||
59
components/jellyseerr/discover/GenericSlideCard.tsx
Normal file
59
components/jellyseerr/discover/GenericSlideCard.tsx
Normal 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;
|
||||
67
components/jellyseerr/discover/GenreSlide.tsx
Normal file
67
components/jellyseerr/discover/GenreSlide.tsx
Normal 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;
|
||||
@@ -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;
|
||||
56
components/jellyseerr/discover/Slide.tsx
Normal file
56
components/jellyseerr/discover/Slide.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
66
components/search/LoadingSkeleton.tsx
Normal file
66
components/search/LoadingSkeleton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
70
components/search/SearchItemWrapper.tsx
Normal file
70
components/search/SearchItemWrapper.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
76
components/settings/AppLanguageSelector.tsx
Normal file
76
components/settings/AppLanguageSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
26
components/settings/DisabledSetting.tsx
Normal file
26
components/settings/DisabledSetting.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
38
components/video-player/controls/VideoTouchOverlay.tsx
Normal file
38
components/video-player/controls/VideoTouchOverlay.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
56
components/video-player/controls/useControlsTimeout.ts
Normal file
56
components/video-player/controls/useControlsTimeout.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
48
components/video-player/controls/useTapDetection.tsx
Normal file
48
components/video-player/controls/useTapDetection.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user