Merge branch 'master' into feat/i18n

This commit is contained in:
Simon Caron
2025-01-03 15:23:17 -05:00
52 changed files with 1833 additions and 1318 deletions

View File

@@ -34,6 +34,7 @@ export const Chromecast: React.FC<Props> = ({
useEffect(() => {
(async () => {
if (!discoveryManager) {
console.warn("DiscoveryManager is not initialized");
return;
}
@@ -64,6 +65,7 @@ export const Chromecast: React.FC<Props> = ({
}}
{...props}
>
<AndroidCastButton />
<Feather name="cast" size={22} color={"white"} />
</RoundButton>
);
@@ -77,6 +79,7 @@ export const Chromecast: React.FC<Props> = ({
}}
{...props}
>
<AndroidCastButton />
<Feather name="cast" size={22} color={"white"} />
</RoundButton>
);

View File

@@ -26,7 +26,7 @@ export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-x
return (
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
{tags.map((tag, idx) => (
<View>
<View key={idx}>
<Tag key={idx} textClass={textClass} text={tag}/>
</View>
))}

View File

@@ -175,6 +175,8 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
) as MediaStream;
}, [source.MediaStreams]);
if (!videoStream) return null;
return (
<View className="flex-row flex-wrap gap-2">
<Badge

View File

@@ -1,35 +0,0 @@
import { PropsWithChildren, ReactNode } from "react";
import { View, ViewProps } from "react-native";
import { Text } from "./common/Text";
interface Props extends ViewProps {
title?: string | null | undefined;
subTitle?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
}
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
title,
subTitle,
iconAfter,
children,
...props
}) => {
return (
<View
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
{...props}
>
<View className="flex flex-col overflow-visible">
<Text className="font-bold ">{title}</Text>
{subTitle && (
<Text uiTextView selectable className="text-xs text-neutral-400">
{subTitle}
</Text>
)}
</View>
{iconAfter}
</View>
);
};

View File

@@ -0,0 +1,51 @@
import React, { useMemo } from "react";
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;
}
interface PreviousServersListProps {
onServerSelect: (server: Server) => void;
}
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
onServerSelect,
}) => {
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
const previousServers = useMemo(() => {
return JSON.parse(_previousServers || "[]") as Server[];
}, [_previousServers]);
const { t } = useTranslation();
if (!previousServers.length) return null;
return (
<View>
<ListGroup title={t("server.previous_servers")} className="mt-4">
{previousServers.map((s) => (
<ListItem
key={s.address}
onPress={() => onServerSelect(s)}
title={s.address}
showArrow
/>
))}
<ListItem
onPress={() => {
setPreviousServers("[]");
}}
title={t("server.clear_button")}
textColor="red"
/>
</ListGroup>
</View>
);
};

View File

@@ -1,6 +1,8 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics";
import {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter, useSegments } from "expo-router";
import { PropsWithChildren } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
@@ -10,8 +12,13 @@ interface Props extends TouchableOpacityProps {
item: BaseItemDto;
}
export const itemRouter = (item: BaseItemDto, from: string) => {
if (item.CollectionType === "livetv") {
export const itemRouter = (
item: BaseItemDto | BaseItemPerson,
from: string
) => {
console.log(item.Type, item?.CollectionType);
if ("CollectionType" in item && item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`;
}
@@ -31,7 +38,7 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
}
if (item.Type === "Person") {
if (item.Type === "Person" || item.Type === "Actor") {
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
}

View File

@@ -84,21 +84,27 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const width = Dimensions.get("screen").width;
if (settings?.usePopularPlugin === false) return null;
if (l1 || l2) return null;
if (!popularItems) return null;
return (
<View className="flex flex-col items-center" {...props}>
<View className="flex flex-col items-center mt-2" {...props}>
<Carousel
autoPlay={true}
autoPlayInterval={3000}
loop={true}
ref={ref}
autoPlay={false}
loop={true}
snapEnabled={true}
mode="parallax"
modeConfig={{
parallaxScrollingScale: 0.86,
parallaxScrollingOffset: 100,
}}
width={width}
height={204}
data={popularItems}
onProgressChange={progress}
renderItem={({ item, index }) => <RenderItem item={item} />}
renderItem={({ item, index }) => <RenderItem key={index} item={item} />}
/>
<Pagination.Basic
progress={progress}

View File

@@ -5,6 +5,7 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { Ionicons } from "@expo/vector-icons";
import {
BaseItemDto,
BaseItemKind,
CollectionType,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
@@ -50,18 +51,52 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
[library]
);
const itemType = useMemo(() => {
let _itemType: BaseItemKind | undefined;
if (library.CollectionType === "movies") {
_itemType = "Movie";
} else if (library.CollectionType === "tvshows") {
_itemType = "Series";
} else if (library.CollectionType === "boxsets") {
_itemType = "BoxSet";
} else if (library.CollectionType === "music") {
_itemType = "MusicAlbum";
}
return _itemType;
}, [library.CollectionType]);
const itemTypeName = useMemo(() => {
let nameStr: string;
if (library.CollectionType === "movies") {
nameStr = "movies";
} else if (library.CollectionType === "tvshows") {
nameStr = "series";
} else if (library.CollectionType === "boxsets") {
nameStr = "box sets";
} else if (library.CollectionType === "music") {
nameStr = "albums";
} else {
nameStr = "items";
}
return nameStr;
}, [library.CollectionType]);
const { data: itemsCount } = useQuery({
queryKey: ["library-count", library.Id],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: library.Id,
recursive: true,
limit: 0,
includeItemTypes: itemType ? [itemType] : undefined,
});
return response.data.TotalRecordCount;
},
staleTime: 1000 * 60 * 60,
});
if (!url) return null;
@@ -80,7 +115,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
</Text>
{settings?.libraryOptions?.showStats && (
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
{itemsCount} items
{itemsCount} {itemTypeName}
</Text>
)}
</View>
@@ -109,6 +144,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
width: "100%",
height: "100%",
}}
cachePolicy={"memory-disk"}
/>
<View
style={{
@@ -128,7 +164,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
)}
{settings?.libraryOptions?.showStats && (
<Text className="font-bold text-xs text-start px-4">
{itemsCount} items
{itemsCount} {itemTypeName}
</Text>
)}
</View>
@@ -145,7 +181,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
</Text>
{settings?.libraryOptions?.showStats && (
<Text className="font-bold text-xs text-neutral-500 text-start px-4">
{itemsCount} items
{itemsCount} {itemTypeName}
</Text>
)}
</View>

View File

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

View File

@@ -0,0 +1,124 @@
import { PropsWithChildren, ReactNode } from "react";
import {
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "../common/Text";
import { Ionicons } from "@expo/vector-icons";
interface Props extends TouchableOpacityProps, ViewProps {
title?: string | null | undefined;
value?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
icon?: keyof typeof Ionicons.glyphMap;
showArrow?: boolean;
textColor?: "default" | "blue" | "red";
onPress?: () => void;
}
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
title,
value,
iconAfter,
children,
showArrow = false,
icon,
textColor = "default",
onPress,
disabled = false,
...props
}) => {
if (onPress)
return (
<TouchableOpacity
disabled={disabled}
onPress={onPress}
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
disabled ? "opacity-50" : ""
}`}
{...props}
>
<ListItemContent
title={title}
value={value}
icon={icon}
textColor={textColor}
showArrow={showArrow}
iconAfter={iconAfter}
>
{children}
</ListItemContent>
</TouchableOpacity>
);
return (
<View
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
disabled ? "opacity-50" : ""
}`}
{...props}
>
<ListItemContent
title={title}
value={value}
icon={icon}
textColor={textColor}
showArrow={showArrow}
iconAfter={iconAfter}
>
{children}
</ListItemContent>
</View>
);
};
const ListItemContent = ({
title,
textColor,
icon,
value,
showArrow,
iconAfter,
children,
...props
}: Props) => {
return (
<>
<View className="flex flex-row items-center w-full">
{icon && (
<View className="border border-neutral-800 rounded-md h-8 w-8 flex items-center justify-center mr-2">
<Ionicons name="person-circle-outline" size={18} color="white" />
</View>
)}
<Text
className={
textColor === "blue"
? "text-[#0584FE]"
: textColor === "red"
? "text-red-600"
: "text-white"
}
numberOfLines={1}
>
{title}
</Text>
{value && (
<View className="ml-auto items-end">
<Text selectable className=" text-[#9899A1]" numberOfLines={1}>
{value}
</Text>
</View>
)}
{children && <View className="ml-auto">{children}</View>}
{showArrow && (
<View className={children ? "ml-1" : "ml-auto"}>
<Ionicons name="chevron-forward" size={18} color="#5A5960" />
</View>
)}
</View>
{iconAfter}
</>
);
};

View File

@@ -61,7 +61,7 @@ export const MediaListSection: React.FC<Props> = ({
return (
<View {...props}>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
{collection.Name}
</Text>
<InfiniteHorizontalScroll

View File

@@ -4,13 +4,14 @@ import {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { router, useSegments } from "expo-router";
import { useAtom } from "jotai";
import React, { useMemo } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import Poster from "../posters/Poster";
import { itemRouter } from "../common/TouchableItemRouter";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -19,6 +20,8 @@ interface Props extends ViewProps {
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
const segments = useSegments();
const from = segments[2];
const destinctPeople = useMemo(() => {
const people: BaseItemPerson[] = [];
@@ -33,6 +36,8 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
return people;
}, [item?.People]);
if (!from) return null;
return (
<View {...props} className="flex flex-col">
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
@@ -44,7 +49,9 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
renderItem={(i) => (
<TouchableOpacity
onPress={() => {
router.push(`/actors/${i.Id}`);
const url = itemRouter(i, from);
// @ts-ignore
router.push(url);
}}
className="flex flex-col w-28"
>

View File

@@ -15,12 +15,13 @@ import { Ionicons } from "@expo/vector-icons";
import { RoundButton } from "@/components/RoundButton";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
import { useQuery } from "@tanstack/react-query";
import {QueryObserverResult, RefetchOptions, useQuery} from "@tanstack/react-query";
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";
const JellyseerrSeasonEpisodes: React.FC<{
details: TvDetails;
@@ -101,7 +102,8 @@ const JellyseerrSeasons: React.FC<{
isLoading: boolean;
result?: TvResult;
details?: TvDetails;
}> = ({ isLoading, result, details }) => {
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
}> = ({ isLoading, result, details, refetch }) => {
if (!details) return null;
const { jellyseerrApi, requestMedia } = useJellyseerr();
@@ -169,6 +171,21 @@ const JellyseerrSeasons: React.FC<{
[requestAll]
);
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
)
}
}, [requestMedia]);
if (isLoading)
return (
<View>
@@ -232,22 +249,7 @@ const JellyseerrSeasons: React.FC<{
return (
<JellyseerrIconStatus
key={0}
onPress={
canRequest
? () =>
requestMedia(
`${result?.name!!}, Season ${
season.seasonNumber
}`,
{
mediaId: details.id,
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
seasons: [season.seasonNumber],
}
)
: undefined
}
onPress={() => requestSeason(canRequest, season.seasonNumber)}
className={canRequest ? "bg-gray-700/40" : undefined}
mediaStatus={
seasons?.find(

View File

@@ -4,6 +4,9 @@ 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";
interface Props extends ViewProps {}
@@ -16,26 +19,35 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
if (!settings) return null;
return (
<View>
<Text className="text-lg font-bold mb-2">{t("home.settings.audio.audio_title")}</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">{t("home.settings.audio.audio_language")}</Text>
<Text className="text-xs opacity-50">
{t("home.settings.audio.audio_language_hint")}
</Text>
</View>
<View {...props}>
<ListGroup
title={t("home.settings.audio.audio_title")}
description={
<Text className="text-[#8E8D91] text-xs">
{t("home.settings.audio.audio_hint")}
</Text>
}
>
<ListItem title={t("home.settings.audio.set_audio_track")}>
<Switch
value={settings.rememberAudioSelections}
onValueChange={(value) =>
updateSettings({ rememberAudioSelections: value })
}
/>
</ListItem>
<ListItem title={t("home.settings.audio.audio_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>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
<Text className="mr-1 text-[#8E8D91]">
{settings?.defaultAudioLanguage?.DisplayName || "None"}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
@@ -74,42 +86,8 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
<Text className="font-semibold">{t("home.settings.audio.use_default_audio")}</Text>
<Text className="text-xs opacity-50">
{t("home.settings.audio.use_default_audio_hint")}
</Text>
</View>
<Switch
value={settings.playDefaultAudioTrack}
onValueChange={(value) =>
updateSettings({ playDefaultAudioTrack: value })
}
/>
</View>
</View>
<View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
<Text className="font-semibold">
{t("home.settings.audio.set_audio_track")}
</Text>
<Text className="text-xs opacity-50 min max-w-[85%]">
{t("home.settings.audio.set_audio_track_hint")}
</Text>
</View>
<Switch
value={settings.rememberAudioSelections}
onValueChange={(value) =>
updateSettings({ rememberAudioSelections: value })
}
/>
</View>
</View>
</View>
</ListItem>
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,113 @@
import { Stepper } from "@/components/inputs/Stepper";
import { useDownload } from "@/providers/DownloadProvider";
import { 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 * 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";
export const DownloadSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const { setProcesses } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslation();
if (!settings) return null;
return (
<View {...props} className="mb-4">
<ListGroup title={t("home.settings.downloads.downloads_title")}>
<ListItem title={t("home.settings.downloads.download_method")}>
<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"}
</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>Methods</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ downloadMethod: "remux" });
setProcesses([]);
}}
>
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ downloadMethod: "optimized" });
setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
<ListItem
title={t("home.settings.downloads.remux_max_download")}
disabled={settings.downloadMethod !== "remux"}
>
<Stepper
value={settings.remuxConcurrentLimit}
step={1}
min={1}
max={4}
onUpdate={(value) =>
updateSettings({
remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"],
})
}
/>
</ListItem>
<ListItem
title={t("home.settings.downloads.auto_download")}
disabled={settings.downloadMethod !== "optimized"}
>
<Switch
disabled={settings.downloadMethod !== "optimized"}
value={settings.autoDownload}
onValueChange={(value) => updateSettings({ autoDownload: value })}
/>
</ListItem>
<ListItem
disabled={settings.downloadMethod !== "optimized"}
onPress={() => router.push("/settings/optimized-server/page")}
showArrow
title={t("home.settings.downloads.optimized_versions_server")}
></ListItem>
</ListGroup>
</View>
);
};

View File

@@ -3,7 +3,7 @@ import { View } from "react-native";
import { Text } from "../common/Text";
import { useCallback, useRef, useState } from "react";
import { Input } from "../common/Input";
import { ListItem } from "../ListItem";
import { ListItem } from "../list/ListItem";
import { Loader } from "../Loader";
import { useSettings } from "@/utils/atoms/settings";
import { Button } from "../Button";
@@ -12,6 +12,7 @@ import { useAtom } from "jotai";
import { toast } from "sonner-native";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { ListGroup } from "../list/ListGroup";
export const JellyseerrSettings = () => {
const {
@@ -86,54 +87,56 @@ export const JellyseerrSettings = () => {
};
return (
<View className="mt-4">
<Text className="text-lg font-bold mb-2">Jellyseerr</Text>
<View className="">
<View>
{jellyseerrUser ? (
<View className="flex flex-col rounded-xl overflow-hidden bg-neutral-900 pt-0 divide-y divide-neutral-800">
<ListItem
title="Total media requests"
subTitle={jellyseerrUser?.requestCount?.toString()}
/>
<ListItem
title="Movie quota limit"
subTitle={
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
}
/>
<ListItem
title="Movie quota days"
subTitle={
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
}
/>
<ListItem
title="TV quota limit"
subTitle={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
/>
<ListItem
title="TV quota days"
subTitle={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
/>
<>
<ListGroup title={"Jellyseerr"}>
<ListItem
title="Total media requests"
value={jellyseerrUser?.requestCount?.toString()}
/>
<ListItem
title="Movie quota limit"
value={
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
}
/>
<ListItem
title="Movie quota days"
value={
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
}
/>
<ListItem
title="TV quota limit"
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
/>
<ListItem
title="TV quota days"
value={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
/>
</ListGroup>
<View className="p-4">
<Button color="red" onPress={clearData}>
Reset Jellyseerr config
</Button>
</View>
</View>
</>
) : (
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
<Text className="text-xs text-red-600 mb-2">
{t("home.settings.jellyseerr.jellyseerr_warning")}
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className="font-bold mb-1">{t("home.settings.jellyseerr.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">
{t("home.settings.jellyseerr.server_url_hint")}
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
</View>
<Input
placeholder={t("home.settings.jellyseerr.server_url_placeholder")}
placeholder={t("home.settings.plugins.jellyseerr.server_url_placeholder")}
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
@@ -163,7 +166,7 @@ export const JellyseerrSettings = () => {
marginBottom: 8,
}}
>
{promptForJellyseerrPass ? t("home.settings.jellyseerr.clear_button") : t("home.settings.jellyseerr.save_button")}
{promptForJellyseerrPass ? t("home.settings.plugins.jellyseerr.clear_button") : t("home.settings.plugins.jellyseerr.save_button")}
</Button>
<View
@@ -172,11 +175,11 @@ export const JellyseerrSettings = () => {
opacity: promptForJellyseerrPass ? 1 : 0.5,
}}
>
<Text className="font-bold mb-2">{t("home.settings.jellyseerr.password")}</Text>
<Text className="font-bold mb-2">{t("home.settings.plugins.jellyseerr.password")}</Text>
<Input
autoFocus={true}
focusable={true}
placeholder={t("home.settings.jellyseerr.password_placeholder", {username: user?.Name})}
placeholder={t("home.settings.plugins.jellyseerr.password_placeholder", {username: user?.Name})}
value={jellyseerrPassword}
keyboardType="default"
secureTextEntry={true}
@@ -196,7 +199,7 @@ export const JellyseerrSettings = () => {
className="h-12 mt-2"
onPress={() => loginToJellyseerrMutation.mutate()}
>
{t("home.settings.jellyseerr.login_button")}
{t("home.settings.plugins.jellyseerr.login_button")}
</Button>
</View>
</View>

View File

@@ -1,5 +1,8 @@
import { useSettings } from "@/utils/atoms/settings";
import React from "react";
import { TouchableOpacity, View, 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";
@@ -11,86 +14,61 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
if (!settings) return null;
return (
<View>
<Text className="text-lg font-bold mb-2">{t("home.settings.media.media_title")}</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">{t("home.settings.media.forward_skip_length")}</Text>
<Text className="text-xs opacity-50">
{t("home.settings.media.forward_skip_length_hint")}
</Text>
</View>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 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 first-letter:px-3 py-2 flex items-center justify-center">
{settings.forwardSkipTime}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</View>
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>
);
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">{t("home.settings.media.rewind_length")}</Text>
<Text className="text-xs opacity-50">
{t("home.settings.media.rewind_length_hint")}
</Text>
</View>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 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 first-letter:px-3 py-2 flex items-center justify-center">
{settings.rewindSkipTime}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</View>
</View>
return (
<View {...props}>
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
<ListItem title={t("home.settings.media_controls.forward_skip_length")}>
{renderSkipControl(
settings.forwardSkipTime,
() =>
updateSettings({
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
}),
() =>
updateSettings({
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
})
)}
</ListItem>
<ListItem title={t("home.settings.media_controls.rewind_length")}>
{renderSkipControl(
settings.rewindSkipTime,
() =>
updateSettings({
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
}),
() =>
updateSettings({
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
})
)}
</ListItem>
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,43 @@
import { TextInput, View, Linking } from "react-native";
import { Text } from "../common/Text";
interface Props {
value: string;
onChangeValue: (value: string) => void;
}
export const OptimizedServerForm: React.FC<Props> = ({
value,
onChangeValue,
}) => {
const handleOpenLink = () => {
Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
};
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>
<TextInput
className="text-white"
placeholder="http(s)://domain.org:port"
value={value}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => onChangeValue(text)}
/>
</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.{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
Read more about the optimize server.
</Text>
</Text>
</View>
);
};

View File

@@ -0,0 +1,186 @@
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync,
unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks";
import { Ionicons } from "@expo/vector-icons";
import * as BackgroundFetch from "expo-background-fetch";
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 { 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";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps {}
export const OtherSettings: React.FC = () => {
const [settings, updateSettings] = useSettings();
const { t } = useTranslation();
/********************
* Background task
*******************/
const checkStatusAsync = async () => {
await BackgroundFetch.getStatusAsync();
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
};
useEffect(() => {
(async () => {
const registered = await checkStatusAsync();
if (settings?.autoDownload === true && !registered) {
registerBackgroundFetchAsync();
toast.success("Background downloads enabled");
} else if (settings?.autoDownload === false && registered) {
unregisterBackgroundFetchAsync();
toast.info("Background downloads disabled");
} else if (settings?.autoDownload === true && registered) {
// Don't to anything
} else if (settings?.autoDownload === false && !registered) {
// Don't to anything
} else {
updateSettings({ autoDownload: false });
}
})();
}, [settings?.autoDownload]);
/**********************
*********************/
if (!settings) return null;
return (
<ListGroup title={t("home.settings.other.other_title")} className="mb-4">
<ListItem title={t("home.settings.other.auto_rotate")}>
<Switch
value={settings.autoRotate}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</ListItem>
<ListItem title={t("home.settings.other.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={t("home.settings.other.safe_area_in_controls")}>
<Switch
value={settings.safeAreaInControlsEnabled}
onValueChange={(value) =>
updateSettings({ safeAreaInControlsEnabled: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.other.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>
</ListGroup>
);
};

View File

@@ -0,0 +1,38 @@
import { useSettings } from "@/utils/atoms/settings";
import { useRouter } from "expo-router";
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={t("home.settings.plugins.plugins_title")}>
<ListItem
onPress={() => router.push("/settings/jellyseerr/page")}
title={"Jellyseerr"}
showArrow
/>
<ListItem
onPress={() => router.push("/settings/marlin-search/page")}
title="Marlin Search"
showArrow
/>
<ListItem
onPress={() => router.push("/settings/popular-lists/page")}
title="Popular Lists"
showArrow
/>
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,61 @@
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 { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps {}
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const openQuickConnectAuthCodeInput = () => {
Alert.prompt(
t("home.settings.quick_connect.quick_connect_title"),
t("home.settings.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(t("home.settings.quick_connect.success"), t("home.settings.quick_connect.quick_connect_autorized"));
} else {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
}
} catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
}
}
}
);
};
return (
<View {...props}>
<ListGroup title={"Quick Connect"}>
<ListItem
onPress={openQuickConnectAuthCodeInput}
title={t("home.settings.quick_connect.authorize_button")}
textColor="blue"
></ListItem>
</ListGroup>
</View>
);
};

View File

@@ -1,672 +0,0 @@
import { useDownload } from "@/providers/DownloadProvider";
import {
apiAtom,
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
import {
ScreenOrientationEnum,
Settings,
useSettings,
} from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync,
unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks";
import { getStatistics } from "@/utils/optimize-server";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import * as BackgroundFetch from "expo-background-fetch";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Linking,
Switch,
TouchableOpacity,
View,
ViewProps,
} from "react-native";
import { toast } from "sonner-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { MediaToggles } from "./MediaToggles";
import { Stepper } from "@/components/inputs/Stepper";
import { MediaProvider } from "./MediaContext";
import { SubtitleToggles } from "./SubtitleToggles";
import { AudioToggles } from "./AudioToggles";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { ListItem } from "@/components/ListItem";
import { JellyseerrSettings } from "./Jellyseerr";
import { useTranslation } from "react-i18next";
import { AppLanguageSelector } from "./AppLanguageSelector";
interface Props extends ViewProps {}
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const { setProcesses } = useDownload();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [marlinUrl, setMarlinUrl] = useState<string>("");
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(settings?.optimizedVersionsServerUrl || "");
const queryClient = useQueryClient();
const { t } = useTranslation();
/********************
* Background task
*******************/
const checkStatusAsync = async () => {
await BackgroundFetch.getStatusAsync();
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
};
useEffect(() => {
(async () => {
const registered = await checkStatusAsync();
if (settings?.autoDownload === true && !registered) {
registerBackgroundFetchAsync();
toast.success(t("home.settings.toasts.background_downloads_enabled"));
} else if (settings?.autoDownload === false && registered) {
unregisterBackgroundFetchAsync();
toast.info(t("home.settings.toasts.background_downloads_disabled"));
} else if (settings?.autoDownload === true && registered) {
// Don't to anything
} else if (settings?.autoDownload === false && !registered) {
// Don't to anything
} else {
updateSettings({ autoDownload: false });
}
})();
}, [settings?.autoDownload]);
/**********************
*********************/
const {
data: mediaListCollections,
isLoading: isLoadingMediaListCollections,
} = useQuery({
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items ?? [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
if (!settings) return null;
return (
<View {...props}>
{/* <View>
<Text className="text-lg font-bold mb-2">Look and feel</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Coming soon</Text>
<Text className="text-xs opacity-50 max-w-[90%]">
Options for changing the look and feel of the app.
</Text>
</View>
<Switch disabled />
</View>
</View>
</View> */}
<AppLanguageSelector />
<MediaProvider>
<MediaToggles />
<AudioToggles />
<SubtitleToggles />
</MediaProvider>
<View>
<Text className="text-lg font-bold mb-2">
{t("home.settings.other.other_title")}
</Text>
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">
{t("home.settings.other.auto_rotate")}
</Text>
<Text className="text-xs opacity-50">
{t("home.settings.other.auto_rotate_hint")}
</Text>
</View>
<Switch
value={settings.autoRotate}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</View>
<View
pointerEvents={settings.autoRotate ? "none" : "auto"}
className={`
${
settings.autoRotate
? "opacity-50 pointer-events-none"
: "opacity-100"
}
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">
{t("home.settings.other.video_orientation")}
</Text>
<Text className="text-xs opacity-50">
{t("home.settings.other.video_orientation_hint")}
</Text>
</View>
<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>
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
</Text>
</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>
</View>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">
{t("home.settings.other.safe_area_in_controls")}
</Text>
<Text className="text-xs opacity-50">
{t("home.settings.other.safe_area_in_controls_hint")}
</Text>
</View>
<Switch
value={settings.safeAreaInControlsEnabled}
onValueChange={(value) =>
updateSettings({ safeAreaInControlsEnabled: value })
}
/>
</View>
<View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
<Text className="font-semibold">
{t("home.settings.other.use_popular_lists_plugin")}
</Text>
<Text className="text-xs opacity-50">
{t("home.settings.other.use_popular_lists_plugin_hint")}
</Text>
<TouchableOpacity
onPress={() => {
Linking.openURL(
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
);
}}
>
<Text className="text-xs text-purple-600">
{t("home.settings.other.more_info")}
</Text>
</TouchableOpacity>
</View>
<Switch
value={settings.usePopularPlugin}
onValueChange={(value) =>
updateSettings({ usePopularPlugin: value })
}
/>
</View>
{settings.usePopularPlugin && (
<View className="flex flex-col py-2 bg-neutral-900">
{mediaListCollections?.map((mlc) => (
<View
key={mlc.Id}
className="flex flex-row items-center justify-between bg-neutral-900 px-4 py-2"
>
<View className="flex flex-col">
<Text className="font-semibold">{mlc.Name}</Text>
</View>
<Switch
value={settings.mediaListCollectionIds?.includes(mlc.Id!)}
onValueChange={(value) => {
if (!settings.mediaListCollectionIds) {
updateSettings({
mediaListCollectionIds: [mlc.Id!],
});
return;
}
updateSettings({
mediaListCollectionIds:
settings.mediaListCollectionIds.includes(mlc.Id!)
? settings.mediaListCollectionIds.filter(
(id) => id !== mlc.Id
)
: [...settings.mediaListCollectionIds, mlc.Id!],
});
}}
/>
</View>
))}
{isLoadingMediaListCollections && (
<View className="flex flex-row items-center justify-center bg-neutral-900 p-4">
<Loader />
</View>
)}
{mediaListCollections?.length === 0 && (
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<Text className="text-xs opacity-50">
No collections found. Add some in Jellyfin.
</Text>
</View>
)}
</View>
)}
</View>
<View className="flex flex-col">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">
{t("home.settings.other.search_engine")}
</Text>
<Text className="text-xs opacity-50">
{t("home.settings.other.search_engine_hint")}
</Text>
</View>
<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>{settings.searchEngine}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ searchEngine: "Marlin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
{settings.searchEngine === "Marlin" && (
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
<View className="flex flex-row items-center space-x-2">
<View className="grow">
<Input
placeholder="Marlin Server URL..."
defaultValue={settings.marlinServerUrl}
value={marlinUrl}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setMarlinUrl(text)}
/>
</View>
<Button
color="purple"
className="shrink w-16 h-12"
onPress={() => {
updateSettings({
marlinServerUrl: marlinUrl.endsWith("/")
? marlinUrl
: marlinUrl + "/",
});
}}
>
{t("home.settings.other.save_button")}
</Button>
</View>
{settings.marlinServerUrl && (
<Text className="text-neutral-500 mt-2">
Current: {settings.marlinServerUrl}
</Text>
)}
</View>
)}
</View>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">
{t("home.settings.other.show_custom_menu_links")}
</Text>
<Text className="text-xs opacity-50">
{t("home.settings.other.show_custom_menu_links_hint")}
</Text>
<TouchableOpacity
onPress={() =>
Linking.openURL(
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
)
}
>
<Text className="text-xs text-purple-600">
{t("home.settings.other.more_info")}
</Text>
</TouchableOpacity>
</View>
<Switch
value={settings.showCustomMenuLinks}
onValueChange={(value) =>
updateSettings({ showCustomMenuLinks: value })
}
/>
</View>
</View>
</View>
<View className="mt-4">
<Text className="text-lg font-bold mb-2">
{t("home.settings.downloads.downloads_title")}
</Text>
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">
{t("home.settings.downloads.download_method")}
</Text>
<Text className="text-xs opacity-50">
{t("home.settings.downloads.download_method_hint")}
</Text>
</View>
<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>
{settings.downloadMethod === "remux"
? "Default"
: "Optimized"}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Methods</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ downloadMethod: "remux" });
setProcesses([]);
}}
>
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ downloadMethod: "optimized" });
setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View
pointerEvents={
settings.downloadMethod === "remux" ? "auto" : "none"
}
className={`
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
${
settings.downloadMethod === "remux"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">
{t("home.settings.downloads.remux_max_download")}
</Text>
<Text className="text-xs opacity-50 shrink">
{t("home.settings.downloads.remux_max_download_hint")}
</Text>
</View>
<Stepper
value={settings.remuxConcurrentLimit}
step={1}
min={1}
max={4}
onUpdate={(value) =>
updateSettings({
remuxConcurrentLimit:
value as Settings["remuxConcurrentLimit"],
})
}
/>
</View>
<View
pointerEvents={
settings.downloadMethod === "optimized" ? "auto" : "none"
}
className={`
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
${
settings.downloadMethod === "optimized"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">
{t("home.settings.downloads.auto_download")}
</Text>
<Text className="text-xs opacity-50 shrink">
{t("home.settings.downloads.auto_download_hint")}
</Text>
</View>
<Switch
value={settings.autoDownload}
onValueChange={(value) => updateSettings({ autoDownload: value })}
/>
</View>
<View
pointerEvents={
settings.downloadMethod === "optimized" ? "auto" : "none"
}
className={`
${
settings.downloadMethod === "optimized"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col bg-neutral-900 px-4 py-4">
<View className="flex flex-col shrink mb-2">
<View className="flex flex-row justify-between items-center">
<Text className="font-semibold">
{t("home.settings.downloads.optimized_versions_server")}
</Text>
</View>
<Text className="text-xs opacity-50">
{t("home.settings.downloads.optimized_versions_server_hint")}
</Text>
</View>
<View></View>
<View className="flex flex-col">
<Input
placeholder="Optimized versions server URL..."
value={optimizedVersionsServerUrl}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setOptimizedVersionsServerUrl(text)}
/>
<Button
color="purple"
className="h-12 mt-2"
onPress={async () => {
updateSettings({
optimizedVersionsServerUrl:
optimizedVersionsServerUrl.length === 0
? null
: optimizedVersionsServerUrl.endsWith("/")
? optimizedVersionsServerUrl
: optimizedVersionsServerUrl + "/",
});
const res = await getStatistics({
url: settings?.optimizedVersionsServerUrl,
authHeader: api?.accessToken,
deviceId: await getOrSetDeviceId(),
});
if (res) {
toast.success(t("home.settings.toasts.connected"));
} else toast.error(t("home.settings.toasts.could_not_connect"));
}}
>
{t("home.settings.downloads.save_button")}
</Button>
</View>
</View>
</View>
</View>
</View>
<JellyseerrSettings />
</View>
);
};

View File

@@ -0,0 +1,105 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
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 { data: size, isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize", appSizeUsage],
queryFn: async () => {
const app = await appSizeUsage;
const remaining = await FileSystem.getFreeDiskStorageAsync();
const total = await FileSystem.getTotalDiskCapacityAsync();
return { app, remaining, total, used: (total - remaining) / total };
},
});
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
toast.error(t("home.settings.toasts.error_deleting_files"));
}
};
const calculatePercentage = (value: number, total: number) => {
return ((value / total) * 100).toFixed(2);
};
return (
<View>
<View className="flex flex-col gap-y-1">
<View className="flex flex-row items-center justify-between">
<Text className="">{t("home.settings.storage.storage_title")}</Text>
{size && (
<Text className="text-neutral-500">
{t("home.settings.storage.size_used", {used: Number(size.total - size.remaining).bytesToReadable(), total: size.total?.bytesToReadable()})}
</Text>
)}
</View>
<View className="h-3 w-full bg-gray-100/10 rounded-md overflow-hidden flex flex-row">
{size && (
<>
<View
style={{
width: `${(size.app / size.total) * 100}%`,
backgroundColor: "rgb(147 51 234)",
}}
/>
<View
style={{
width: `${
((size.total - size.remaining - size.app) / size.total) *
100
}%`,
backgroundColor: "rgb(192 132 252)",
}}
/>
</>
)}
</View>
<View className="flex flex-row gap-x-2">
{size && (
<>
<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">
{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">
{t("home.settings.storage.phone_usage", {availableSpace: calculatePercentage(size.total - size.remaining - size.app, size.total)})}
</Text>
</View>
</>
)}
</View>
</View>
<ListGroup>
<ListItem
textColor="red"
onPress={onDeleteClicked}
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
</View>
);
};

View File

@@ -3,6 +3,9 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { useMedia } from "./MediaContext";
import { Switch } from "react-native-gesture-handler";
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";
@@ -25,26 +28,27 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
];
return (
<View>
<Text className="text-lg font-bold mb-2">{t("home.settings.subtitles.subtitle_title")}</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">{t("home.settings.subtitles.subtitle_language")}</Text>
<Text className="text-xs opacity-50">
{t("home.settings.subtitles.subtitle_language_hint")}
</Text>
</View>
<View {...props}>
<ListGroup
title={t("home.settings.subtitles.subtitle_title")}
description={
<Text className="text-[#8E8D91] text-xs">
{t("home.settings.subtitles.subtitle_hint")}
</Text>
}
>
<ListItem title={t("home.settings.subtitles.subtitle_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>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
@@ -83,23 +87,20 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
</ListItem>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">{t("home.settings.subtitles.subtitle_mode")}</Text>
<Text className="text-xs opacity-50 mr-2">
{t("home.settings.subtitles.subtitle_mode_hint")}
</Text>
</View>
<ListItem title={t("home.settings.subtitles.subtitle_mode")}>
<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>{settings?.subtitleMode || "Loading"}</Text>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings?.subtitleMode || "Loading"}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
@@ -126,38 +127,18 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
</ListItem>
<View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
<Text className="font-semibold">
{t("home.settings.subtitles.set_subtitle_track")}
</Text>
<Text className="text-xs opacity-50 min max-w-[85%]">
{t("home.settings.subtitles.set_subtitle_track_hint")}
</Text>
</View>
<Switch
value={settings.rememberSubtitleSelections}
onValueChange={(value) =>
updateSettings({ rememberSubtitleSelections: value })
}
/>
</View>
</View>
<ListItem title={t("home.settings.subtitles.set_subtitle_track")}>
<Switch
value={settings.rememberSubtitleSelections}
onValueChange={(value) =>
updateSettings({ rememberSubtitleSelections: value })
}
/>
</ListItem>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">{t("home.settings.subtitles.subtitle_size")}</Text>
<Text className="text-xs opacity-50">
{t("home.settings.subtitles.subtitle_size_hint")}
</Text>
</View>
<ListItem title={t("home.settings.subtitles.subtitle_size")}>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
@@ -169,7 +150,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
<Text className="w-12 h-8 bg-neutral-800 px-3 py-2 flex items-center justify-center">
{settings.subtitleSize}
</Text>
<TouchableOpacity
@@ -183,8 +164,8 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
<Text>+</Text>
</TouchableOpacity>
</View>
</View>
</View>
</ListItem>
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,34 @@
import { 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 { 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 ||
Application?.nativeBuildVersion ||
"N/A";
return (
<View {...props}>
<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>
);
};