diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index 02e06d3d..05fd2156 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -88,7 +88,6 @@ export const AudioTrackSelector: React.FC = ({ hostStyle: { flex: 1 }, }} bottomSheetConfig={{ - enableDynamicSizing: true, enablePanDownToClose: true, }} /> diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index fa8b01d6..df1f383a 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -136,7 +136,6 @@ export const BitrateSelector: React.FC = ({ hostStyle: { flex: 1 }, }} bottomSheetConfig={{ - enableDynamicSizing: true, enablePanDownToClose: true, }} /> diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index 92273a3c..6c6866d3 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -104,7 +104,6 @@ export const MediaSourceSelector: React.FC = ({ hostStyle: { flex: 1 }, }} bottomSheetConfig={{ - enableDynamicSizing: true, enablePanDownToClose: true, }} /> diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index 585eea25..06ee57cc 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -193,7 +193,7 @@ export function PlatformDropdown({ onOptionSelect={onOptionSelect} />, { - enableDynamicSizing: bottomSheetConfig?.enableDynamicSizing ?? true, + snapPoints: ["90%"], enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true, }, ); @@ -214,7 +214,7 @@ export function PlatformDropdown({ - + {trigger || } diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 7147f90f..ac32569b 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -114,7 +114,6 @@ export const SubtitleTrackSelector: React.FC = ({ hostStyle: { flex: 1 }, }} bottomSheetConfig={{ - enableDynamicSizing: true, enablePanDownToClose: true, }} /> diff --git a/components/common/Dropdown.tsx b/components/common/Dropdown.tsx deleted file mode 100644 index e2795d12..00000000 --- a/components/common/Dropdown.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { - type PropsWithChildren, - type ReactNode, - useEffect, - useMemo, - useState, -} from "react"; -import { Platform, TouchableOpacity, View, type ViewProps } from "react-native"; -import { Text } from "@/components/common/Text"; -import DisabledSetting from "@/components/settings/DisabledSetting"; -import { type OptionGroup, PlatformDropdown } from "../PlatformDropdown"; - -interface Props { - data: T[]; - disabled?: boolean; - placeholderText?: string; - keyExtractor: (item: T) => string; - titleExtractor: (item: T) => string | undefined; - title: string | ReactNode; - label: string; - onSelected: (...item: T[]) => void; - multiple?: boolean; -} - -const Dropdown = ({ - data, - disabled, - placeholderText, - keyExtractor, - titleExtractor, - title, - label, - onSelected, - multiple = false, - ...props -}: PropsWithChildren & ViewProps>) => { - const isTv = Platform.isTV; - const [open, setOpen] = useState(false); - const [selected, setSelected] = useState(); - - useEffect(() => { - if (selected !== undefined) { - onSelected(...selected); - } - }, [selected, onSelected]); - - const handleOptionSelect = (optionId: string, value?: any) => { - const selectedItem = data.find((item) => keyExtractor(item) === optionId); - if (!selectedItem) return; - - if (multiple) { - setSelected((prev) => { - const prevItems = prev || []; - if (value) { - // Add item if not already selected - if (!prevItems.some((s) => keyExtractor(s) === optionId)) { - return [...prevItems, selectedItem]; - } - return prevItems; - } else { - // Remove item - return prevItems.filter((s) => keyExtractor(s) !== optionId); - } - }); - } else { - setSelected([selectedItem]); - setOpen(false); - } - }; - - const optionGroups: OptionGroup[] = useMemo( - () => [ - { - title: label, - options: data.map((item) => { - const key = keyExtractor(item); - const isSelected = - selected?.some((s) => keyExtractor(s) === key) || false; - - if (multiple) { - return { - type: "toggle" as const, - label: titleExtractor(item) || key, - value: isSelected, - onToggle: () => handleOptionSelect(key, !isSelected), - }; - } - - return { - type: "radio" as const, - label: titleExtractor(item) || key, - value: key, - selected: isSelected, - onPress: () => handleOptionSelect(key), - }; - }), - }, - ], - [ - data, - selected, - multiple, - keyExtractor, - titleExtractor, - label, - handleOptionSelect, - ], - ); - - const getDisplayValue = () => { - if (selected?.length !== undefined && selected.length > 0) { - return selected.map(titleExtractor).join(","); - } - return placeholderText || ""; - }; - - const trigger = - typeof title === "string" ? ( - - {title} - setOpen(true)} - disabled={disabled} - > - {getDisplayValue()} - - - ) : ( - setOpen(true)} disabled={disabled}> - {title} - - ); - - if (isTv) return null; - - return ( - - - - ); -}; - -export default Dropdown; diff --git a/components/jellyseerr/RequestModal.tsx b/components/jellyseerr/RequestModal.tsx index 0dc521a7..129d6c8c 100644 --- a/components/jellyseerr/RequestModal.tsx +++ b/components/jellyseerr/RequestModal.tsx @@ -8,10 +8,10 @@ import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescrip import { useQuery } from "@tanstack/react-query"; import { forwardRef, useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { View, type ViewProps } from "react-native"; +import { TouchableOpacity, View, type ViewProps } from "react-native"; import { Button } from "@/components/Button"; -import Dropdown from "@/components/common/Dropdown"; import { Text } from "@/components/common/Text"; +import { PlatformDropdown } from "@/components/PlatformDropdown"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import type { QualityProfile, @@ -138,6 +138,115 @@ const RequestModal = forwardRef< }); }, [requestBody?.seasons]); + const pathTitleExtractor = (item: RootFolder) => + `${item.path} (${item.freeSpace.bytesToReadable()})`; + + const qualityProfileOptions = useMemo( + () => [ + { + title: t("jellyseerr.quality_profile"), + options: + defaultServiceDetails?.profiles.map((profile) => ({ + type: "radio" as const, + label: profile.name, + value: profile.id.toString(), + selected: + (requestOverrides.profileId || defaultProfile?.id) === + profile.id, + onPress: () => + setRequestOverrides((prev) => ({ + ...prev, + profileId: profile.id, + })), + })) || [], + }, + ], + [ + defaultServiceDetails?.profiles, + defaultProfile, + requestOverrides.profileId, + t, + ], + ); + + const rootFolderOptions = useMemo( + () => [ + { + title: t("jellyseerr.root_folder"), + options: + defaultServiceDetails?.rootFolders.map((folder) => ({ + type: "radio" as const, + label: pathTitleExtractor(folder), + value: folder.id.toString(), + selected: + (requestOverrides.rootFolder || defaultFolder?.path) === + folder.path, + onPress: () => + setRequestOverrides((prev) => ({ + ...prev, + rootFolder: folder.path, + })), + })) || [], + }, + ], + [ + defaultServiceDetails?.rootFolders, + defaultFolder, + requestOverrides.rootFolder, + t, + ], + ); + + const tagsOptions = useMemo( + () => [ + { + title: t("jellyseerr.tags"), + options: + defaultServiceDetails?.tags.map((tag) => ({ + type: "toggle" as const, + label: tag.label, + value: + requestOverrides.tags?.includes(tag.id) || + defaultTags.some((dt) => dt.id === tag.id), + onToggle: () => + setRequestOverrides((prev) => { + const currentTags = prev.tags || defaultTags.map((t) => t.id); + const hasTag = currentTags.includes(tag.id); + return { + ...prev, + tags: hasTag + ? currentTags.filter((id) => id !== tag.id) + : [...currentTags, tag.id], + }; + }), + })) || [], + }, + ], + [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags, t], + ); + + const usersOptions = useMemo( + () => [ + { + title: t("jellyseerr.request_as"), + options: + users?.map((user) => ({ + type: "radio" as const, + label: user.displayName, + value: user.id.toString(), + selected: + (requestOverrides.userId || jellyseerrUser?.id) === user.id, + onPress: () => + setRequestOverrides((prev) => ({ + ...prev, + userId: user.id, + })), + })) || [], + }, + ], + [users, jellyseerrUser, requestOverrides.userId, t], + ); + const request = useCallback(() => { const body = { is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k, @@ -163,9 +272,6 @@ const RequestModal = forwardRef< defaultTags, ]); - const pathTitleExtractor = (item: RootFolder) => - `${item.path} (${item.freeSpace.bytesToReadable()})`; - return ( {defaultService && defaultServiceDetails && users && ( <> - item.name} - placeholderText={ - requestOverrides.profileName || defaultProfile.name - } - keyExtractor={(item) => item.id.toString()} - label={t("jellyseerr.quality_profile")} - onSelected={(item) => - item && - setRequestOverrides((prev) => ({ - ...prev, - profileId: item?.id, - })) - } - title={t("jellyseerr.quality_profile")} - /> - item.id.toString()} - label={t("jellyseerr.root_folder")} - onSelected={(item) => - item && - setRequestOverrides((prev) => ({ - ...prev, - rootFolder: item.path, - })) - } - title={t("jellyseerr.root_folder")} - /> - item.label} - placeholderText={defaultTags.map((t) => t.label).join(",")} - keyExtractor={(item) => item.id.toString()} - label={t("jellyseerr.tags")} - onSelected={(...selected) => - setRequestOverrides((prev) => ({ - ...prev, - tags: selected.map((i) => i.id), - })) - } - title={t("jellyseerr.tags")} - /> - 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")} - /> + + + {t("jellyseerr.quality_profile")} + + + + {defaultServiceDetails.profiles.find( + (p) => + p.id === + (requestOverrides.profileId || + defaultProfile?.id), + )?.name || defaultProfile?.name} + + + } + title={t("jellyseerr.quality_profile")} + /> + + + + + {t("jellyseerr.root_folder")} + + + + {defaultServiceDetails.rootFolders.find( + (f) => + f.path === + (requestOverrides.rootFolder || + defaultFolder?.path), + ) + ? pathTitleExtractor( + defaultServiceDetails.rootFolders.find( + (f) => + f.path === + (requestOverrides.rootFolder || + defaultFolder?.path), + )!, + ) + : pathTitleExtractor(defaultFolder!)} + + + } + title={t("jellyseerr.root_folder")} + /> + + + + + {t("jellyseerr.tags")} + + + + {requestOverrides.tags + ? defaultServiceDetails.tags + .filter((t) => + requestOverrides.tags!.includes(t.id), + ) + .map((t) => t.label) + .join(", ") || + defaultTags.map((t) => t.label).join(", ") + : defaultTags.map((t) => t.label).join(", ")} + + + } + title={t("jellyseerr.tags")} + /> + + + + + {t("jellyseerr.request_as")} + + + + {users.find( + (u) => + u.id === + (requestOverrides.userId || jellyseerrUser?.id), + )?.displayName || jellyseerrUser!.displayName} + + + } + title={t("jellyseerr.request_as")} + /> + )} diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx index d4d7e598..5e73d99f 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -5,10 +5,10 @@ import { TFunction } from "i18next"; import type React from "react"; import { useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Linking, Platform, Switch, TouchableOpacity } from "react-native"; +import { Linking, Platform, Switch, View } from "react-native"; import { toast } from "sonner-native"; import { BITRATES } from "@/components/BitrateSelector"; -import Dropdown from "@/components/common/Dropdown"; +import { PlatformDropdown } from "@/components/PlatformDropdown"; import DisabledSetting from "@/components/settings/DisabledSetting"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; @@ -89,6 +89,55 @@ export const OtherSettings: React.FC = () => { [], ); + const orientationOptions = useMemo( + () => [ + { + title: t("home.settings.other.orientation"), + options: orientations.map((orientation) => ({ + type: "radio" as const, + label: t(ScreenOrientationEnum[orientation]), + value: String(orientation), + selected: orientation === settings?.defaultVideoOrientation, + onPress: () => + updateSettings({ defaultVideoOrientation: orientation }), + })), + }, + ], + [orientations, settings?.defaultVideoOrientation, t, updateSettings], + ); + + const bitrateOptions = useMemo( + () => [ + { + title: t("home.settings.other.default_quality"), + options: BITRATES.map((bitrate) => ({ + type: "radio" as const, + label: bitrate.key, + value: bitrate.key, + selected: bitrate.key === settings?.defaultBitrate?.key, + onPress: () => updateSettings({ defaultBitrate: bitrate }), + })), + }, + ], + [settings?.defaultBitrate?.key, t, updateSettings], + ); + + const autoPlayEpisodeOptions = useMemo( + () => [ + { + title: t("home.settings.other.max_auto_play_episode_count"), + options: AUTOPLAY_EPISODES_COUNT(t).map((item) => ({ + type: "radio" as const, + label: item.key, + value: item.key, + selected: item.key === settings?.maxAutoPlayEpisodeCount?.key, + onPress: () => updateSettings({ maxAutoPlayEpisodeCount: item }), + })), + }, + ], + [settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings], + ); + if (!settings) return null; return ( @@ -114,16 +163,10 @@ export const OtherSettings: React.FC = () => { settings.followDeviceOrientation } > - t(ScreenOrientationEnum[item])} - title={ - + {t( orientationTranslations[ @@ -136,12 +179,9 @@ export const OtherSettings: React.FC = () => { size={18} color='#5A5960' /> - - } - label={t("home.settings.other.orientation")} - onSelected={(defaultVideoOrientation) => - updateSettings({ defaultVideoOrientation }) + } + title={t("home.settings.other.orientation")} /> @@ -214,13 +254,10 @@ export const OtherSettings: React.FC = () => { title={t("home.settings.other.default_quality")} disabled={pluginSettings?.defaultBitrate?.locked} > - item.key} - titleExtractor={(item) => item.key} - title={ - + {settings.defaultBitrate?.key} @@ -229,10 +266,9 @@ export const OtherSettings: React.FC = () => { size={18} color='#5A5960' /> - + } - label={t("home.settings.other.default_quality")} - onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })} + title={t("home.settings.other.default_quality")} /> { /> - item.key} - titleExtractor={(item) => item.key} - title={ - + {t(settings?.maxAutoPlayEpisodeCount.key)} @@ -262,12 +296,9 @@ export const OtherSettings: React.FC = () => { size={18} color='#5A5960' /> - - } - label={t("home.settings.other.max_auto_play_episode_count")} - onSelected={(maxAutoPlayEpisodeCount) => - updateSettings({ maxAutoPlayEpisodeCount }) + } + title={t("home.settings.other.max_auto_play_episode_count")} /> diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index e1be30d4..749d81be 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -1,14 +1,15 @@ import { Ionicons } from "@expo/vector-icons"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Platform, TouchableOpacity, View, type ViewProps } from "react-native"; +import { Platform, View, type ViewProps } from "react-native"; import { Switch } from "react-native-gesture-handler"; -import Dropdown from "@/components/common/Dropdown"; import { Stepper } from "@/components/inputs/Stepper"; import { useSettings } from "@/utils/atoms/settings"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; +import { PlatformDropdown } from "../PlatformDropdown"; import { useMedia } from "./MediaContext"; interface Props extends ViewProps {} @@ -22,9 +23,6 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { const cultures = media.cultures; const { t } = useTranslation(); - if (isTv) return null; - if (!settings) return null; - const subtitleModes = [ SubtitlePlaybackMode.Default, SubtitlePlaybackMode.Smart, @@ -42,6 +40,57 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { [SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None", }; + const subtitleLanguageOptionGroups = useMemo(() => { + const options = [ + { + type: "radio" as const, + label: t("home.settings.subtitles.none"), + value: "none", + selected: !settings?.defaultSubtitleLanguage, + onPress: () => updateSettings({ defaultSubtitleLanguage: null }), + }, + ...(cultures?.map((culture) => ({ + type: "radio" as const, + label: culture.DisplayName || "Unknown", + value: + culture.ThreeLetterISOLanguageName || + culture.DisplayName || + "unknown", + selected: + culture.ThreeLetterISOLanguageName === + settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName, + onPress: () => updateSettings({ defaultSubtitleLanguage: culture }), + })) || []), + ]; + + return [ + { + title: t("home.settings.subtitles.language"), + options, + }, + ]; + }, [cultures, settings?.defaultSubtitleLanguage, t, updateSettings]); + + const subtitleModeOptionGroups = useMemo(() => { + const options = subtitleModes.map((mode) => ({ + type: "radio" as const, + label: t(subtitleModeKeys[mode]) || String(mode), + value: String(mode), + selected: mode === settings?.subtitleMode, + onPress: () => updateSettings({ subtitleMode: mode }), + })); + + return [ + { + title: t("home.settings.subtitles.subtitle_mode"), + options, + }, + ]; + }, [settings?.subtitleMode, t, updateSettings]); + + if (isTv) return null; + if (!settings) return null; + return ( = ({ ...props }) => { } > - - item?.ThreeLetterISOLanguageName ?? "unknown" - } - titleExtractor={(item) => item?.DisplayName} - title={ - + {settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")} @@ -76,18 +115,9 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { size={18} color='#5A5960' /> - - } - label={t("home.settings.subtitles.language")} - onSelected={(defaultSubtitleLanguage) => - updateSettings({ - defaultSubtitleLanguage: - defaultSubtitleLanguage.DisplayName === - t("home.settings.subtitles.none") - ? null - : defaultSubtitleLanguage, - }) + } + title={t("home.settings.subtitles.language")} /> @@ -95,13 +125,10 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { title={t("home.settings.subtitles.subtitle_mode")} disabled={pluginSettings?.subtitleMode?.locked} > - t(subtitleModeKeys[item]) || String(item)} - title={ - + {t(subtitleModeKeys[settings?.subtitleMode]) || t("home.settings.subtitles.loading")} @@ -111,10 +138,9 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { size={18} color='#5A5960' /> - + } - label={t("home.settings.subtitles.subtitle_mode")} - onSelected={(subtitleMode) => updateSettings({ subtitleMode })} + title={t("home.settings.subtitles.subtitle_mode")} />