From 5e6cd6bed628e08a83bca617f2d3c1bcdd81b717 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 30 Sep 2025 08:26:45 +0200 Subject: [PATCH] wip: remove zeego + expo ui --- .../jellyseerr/page.tsx | 94 ++-- bun.lock | 86 +--- components/AudioTrackSelector.tsx | 107 ++-- components/BitrateSelector.tsx | 99 ++-- components/MediaSourceSelector.tsx | 100 ++-- components/PlatformOptionsMenu.tsx | 477 ++++++++++++++++++ components/SubtitleTrackSelector.tsx | 136 +++-- components/common/Dropdown.tsx | 163 +++--- components/common/JellyseerrItemRouter.tsx | 109 +--- components/series/SeasonDropdown.tsx | 109 ++-- components/settings/AppLanguageSelector.tsx | 116 +++-- components/settings/AudioToggles.tsx | 141 +++--- components/settings/SubtitleToggles.tsx | 5 +- package.json | 8 +- 14 files changed, 1141 insertions(+), 609 deletions(-) create mode 100644 components/PlatformOptionsMenu.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx index 12e1c82e..60b96e86 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx @@ -19,31 +19,32 @@ import { Text } from "@/components/common/Text"; import { GenreTags } from "@/components/GenreTags"; import Cast from "@/components/jellyseerr/Cast"; import DetailFacts from "@/components/jellyseerr/DetailFacts"; +import RequestModal from "@/components/jellyseerr/RequestModal"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; +import { + type OptionGroup, + PlatformOptionsMenu, +} from "@/components/PlatformOptionsMenu"; import { JellyserrRatings } from "@/components/Ratings"; import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; import { ItemActions } from "@/components/series/SeriesActions"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; +import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { type IssueType, IssueTypeName, } from "@/utils/jellyseerr/server/constants/issue"; import { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { MovieResult, TvResult, } from "@/utils/jellyseerr/server/models/Search"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - -import RequestModal from "@/components/jellyseerr/RequestModal"; -import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; -import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; -import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; - const Page: React.FC = () => { const insets = useSafeAreaInsets(); const params = useLocalSearchParams(); @@ -65,6 +66,7 @@ const Page: React.FC = () => { const [issueType, setIssueType] = useState(); const [issueMessage, setIssueMessage] = useState(); const [requestBody, _setRequestBody] = useState(); + const [issueTypeMenuOpen, setIssueTypeMenuOpen] = useState(false); const advancedReqModalRef = useRef(null); const bottomSheetModalRef = useRef(null); @@ -156,6 +158,30 @@ const Page: React.FC = () => { [details], ); + const issueTypeOptionGroups: OptionGroup[] = useMemo( + () => [ + { + id: "issue-types", + title: t("jellyseerr.types"), + options: Object.entries(IssueTypeName) + .reverse() + .map(([key, value]) => ({ + id: key, + type: "radio" as const, + groupId: "issue-types", + label: value, + selected: key === String(issueType), + })), + }, + ], + [issueType, t], + ); + + const handleIssueTypeSelect = (optionId: string) => { + setIssueType(optionId as unknown as IssueType); + setIssueTypeMenuOpen(false); + }; + useEffect(() => { if (details) { navigation.setOptions({ @@ -365,49 +391,37 @@ const Page: React.FC = () => { - - + {t("jellyseerr.issue_type")} - - + setIssueTypeMenuOpen(true)} + > + {issueType ? IssueTypeName[issueType] : t("jellyseerr.select_an_issue")} - - - - {t("jellyseerr.types")} - - {Object.entries(IssueTypeName) - .reverse() - .map(([key, value], _idx) => ( - - setIssueType(key as unknown as IssueType) - } - > - - {value} - - - ))} - - + } + title={t("jellyseerr.types")} + open={issueTypeMenuOpen} + onOpenChange={setIssueTypeMenuOpen} + onOptionSelect={handleIssueTypeSelect} + expoUIConfig={{ + hostStyle: { flex: 1 }, + }} + bottomSheetConfig={{ + enableDynamicSizing: true, + enablePanDownToClose: true, + }} + /> diff --git a/bun.lock b/bun.lock index 3b03d8d2..a42da693 100644 --- a/bun.lock +++ b/bun.lock @@ -7,10 +7,11 @@ "@bottom-tabs/react-navigation": "^0.11.2", "@expo/metro-runtime": "~6.1.1", "@expo/react-native-action-sheet": "^4.1.1", + "@expo/ui": "~0.2.0-beta.4", "@expo/vector-icons": "^15.0.2", "@gorhom/bottom-sheet": "^5.1.0", "@jellyfin/sdk": "^0.11.0", - "@kesha-antonov/react-native-background-downloader": "github:kesha-antonov/react-native-background-downloader#main", + "@kesha-antonov/react-native-background-downloader": "github:fredrikburmester/react-native-background-downloader#f3bf69ad124b6ec6adbc30c7f688935d0376fc56", "@react-native-community/netinfo": "^11.4.1", "@react-native-menu/menu": "^1.2.4", "@react-navigation/material-top-tabs": "^7.2.14", @@ -64,8 +65,8 @@ "react-native-google-cast": "^4.9.0", "react-native-image-colors": "^2.4.0", "react-native-ios-context-menu": "^3.1.0", - "react-native-ios-utilities": "5.1.8", - "react-native-mmkv": "4.0.0-beta.6", + "react-native-ios-utilities": "5.2.0", + "react-native-mmkv": "4.0.0-beta.12", "react-native-nitro-modules": "^0.29.1", "react-native-pager-view": "^6.9.1", "react-native-reanimated": "~4.1.0", @@ -83,7 +84,6 @@ "sonner-native": "^0.21.0", "tailwindcss": "3.3.2", "use-debounce": "^10.0.4", - "zeego": "^3.0.6", "zod": "^4.1.3", }, "devDependencies": { @@ -377,20 +377,14 @@ "@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="], + "@expo/ui": ["@expo/ui@0.2.0-beta.4", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-49DoNCQ5jyLFvnTZpVU1aMSbfgRtX4QQtnX7WM97A7dJhocvaF7g7MA3LBeoZ07MMVKuuwfcIBM2F/f52S+6zw=="], + "@expo/vector-icons": ["@expo/vector-icons@15.0.2", "", { "peerDependencies": { "expo-font": ">=14.0.4", "react": "*", "react-native": "*" } }, "sha512-IiBjg7ZikueuHNf40wSGCf0zS73a3guJLdZzKnDUxsauB8VWPLMeWnRIupc+7cFhLUkqyvyo0jLNlcxG5xPOuQ=="], "@expo/ws-tunnel": ["@expo/ws-tunnel@1.0.6", "", {}, "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q=="], "@expo/xcpretty": ["@expo/xcpretty@4.3.2", "", { "dependencies": { "@babel/code-frame": "7.10.4", "chalk": "^4.1.0", "find-up": "^5.0.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw=="], - "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], - - "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], - - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="], - - "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.6", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-vmruJxdiUGDg+ZYcDmS30XDhq/h/+QkINOI5LY/uGjx8cPGwgJW0H6AB902gNTKtccbiKe/rr94EwdmIEz+LAQ=="], "@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="], @@ -459,7 +453,7 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@kesha-antonov/react-native-background-downloader": ["@kesha-antonov/react-native-background-downloader@github:kesha-antonov/react-native-background-downloader#11c0ab2", { "peerDependencies": { "react-native": ">=0.57.0" } }, "kesha-antonov-react-native-background-downloader-11c0ab2"], + "@kesha-antonov/react-native-background-downloader": ["@kesha-antonov/react-native-background-downloader@github:fredrikburmester/react-native-background-downloader#f3bf69a", { "peerDependencies": { "react-native": ">=0.57.0" } }, "fredrikburmester-react-native-background-downloader-f3bf69a"], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -471,34 +465,24 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], - "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], - "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], - "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], - "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], - "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], - - "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], - "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], @@ -521,12 +505,6 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], - - "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], - - "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - "@react-native-community/cli": ["@react-native-community/cli@20.0.2", "", { "dependencies": { "@react-native-community/cli-clean": "20.0.2", "@react-native-community/cli-config": "20.0.2", "@react-native-community/cli-doctor": "20.0.2", "@react-native-community/cli-server-api": "20.0.2", "@react-native-community/cli-tools": "20.0.2", "@react-native-community/cli-types": "20.0.2", "chalk": "^4.1.2", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-ocgRFKRLX8b5rEK38SJfpr0AMl6SqseWljk6c5LxCG/zpCfPPNQdXq1OsDvmEwsqO4OEQ6tmOaSm9OgTm6FhbQ=="], "@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.0.2", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "sha512-hfbC69fTD0fqZCCep8aqnVztBXUhAckNhi76lEV7USENtgBRwNq2s1wATgKAzOhxKuAL9TEkf5TZ/Dhp/YLhCQ=="], @@ -1575,7 +1553,7 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], - "postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss-calc": ["postcss-calc@8.2.4", "", { "dependencies": { "postcss-selector-parser": "^6.0.9", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.2.2" } }, "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q=="], @@ -1671,11 +1649,11 @@ "react-native-ios-context-menu": ["react-native-ios-context-menu@3.2.1", "", { "dependencies": { "@dominicstop/ts-event-emitter": "^1.1.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-ios-utilities": "*" } }, "sha512-OBQbb3I/VUx2wQgz4cqN614kt3nJ+qx5wxEdtGN1Aj4nYYL1orp7VLFkV6axof6DgOyv0YD6af2RUTok6a2xDQ=="], - "react-native-ios-utilities": ["react-native-ios-utilities@5.1.8", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-2lWerAkd0Kn18kUAc/RaBzHnOGG1VjbKVfTR4eEXvwYFYqCS709gOg0tGUaVLsm6CAyMe7/jA+AvKMMztzHf4g=="], + "react-native-ios-utilities": ["react-native-ios-utilities@5.2.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-RTw1Gk8rQhBL43+U80I+Nu8T7mLTNkj5RaG8vTs3ETEDqphS3L0Mrzk79RX0Jmm64HMad70GXHctXFlW1n0V8w=="], "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], - "react-native-mmkv": ["react-native-mmkv@4.0.0-beta.6", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-UTEIAUZAv3EHvDKqJB7Z7W3f22zyLI9GjCGbW6aM5fT7GIg2i90V/vb+CnTlEqCZ1R5uI1ox604RK151t7sSSQ=="], + "react-native-mmkv": ["react-native-mmkv@4.0.0-beta.12", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-iSqvhnA9GGWXbJiktNmT61C/B+WrOQfhd22Npqo+KjkOOsGibxB9nQF6G+LxUr9CNEc5+PAwAaRSWKLBxECA1Q=="], "react-native-nitro-modules": ["react-native-nitro-modules@0.29.6", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Zm7x7DPx2bYc/eMfJ4lg2CCyXRCEm6as1ZyVU/2vCqMskiQxQquL3INqjne+tEJw+/h+mrnKrb7z7PiUitzEQw=="], @@ -2035,8 +2013,6 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zeego": ["zeego@3.0.6", "", { "dependencies": { "@radix-ui/react-context-menu": "^2.0.1", "@radix-ui/react-dropdown-menu": "^2.0.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "@react-native-menu/menu": "1.2.2", "react": "*", "react-native": "*", "react-native-ios-context-menu": "3.1.0", "react-native-ios-utilities": "5.1.2" } }, "sha512-vg0GCMPYg6or/J91bwRnUpIYwz7QnhkyeKOdd3FjvICg+Gzq2D5QhD8k5RUSv1B+048LpNmNYdLm8qJVIbBONw=="], - "zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="], @@ -2111,6 +2087,8 @@ "@expo/metro-config/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], + "@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], + "@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], "@expo/prebuild-config/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], @@ -2137,8 +2115,6 @@ "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@react-native-community/cli/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], @@ -2149,12 +2125,6 @@ "@react-native-community/cli-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - "@react-native/community-cli-plugin/metro": ["metro@0.83.2", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-config": "0.83.2", "metro-core": "0.83.2", "metro-file-map": "0.83.2", "metro-resolver": "0.83.2", "metro-runtime": "0.83.2", "metro-source-map": "0.83.2", "metro-symbolicate": "0.83.2", "metro-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-HQgs9H1FyVbRptNSMy/ImchTTE5vS2MSqLoOo7hbDoBq6hPPZokwJvBMwrYSxdjQZmLXz2JFZtdvS+ZfgTc9yw=="], - - "@react-native/community-cli-plugin/metro-config": ["metro-config@0.83.2", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.2", "metro-cache": "0.83.2", "metro-core": "0.83.2", "metro-runtime": "0.83.2", "yaml": "^2.6.1" } }, "sha512-1FjCcdBe3e3D08gSSiU9u3Vtxd7alGH3x/DNFqWDFf5NouX4kLgbVloDDClr1UrLz62c0fHh2Vfr9ecmrOZp+g=="], - - "@react-native/community-cli-plugin/metro-core": ["metro-core@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.2" } }, "sha512-8DRb0O82Br0IW77cNgKMLYWUkx48lWxUkvNUxVISyMkcNwE/9ywf1MYQUE88HaKwSrqne6kFgCSA/UWZoUT0Iw=="], - "@react-native/community-cli-plugin/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "@react-navigation/bottom-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], @@ -2163,8 +2133,6 @@ "@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], - "@types/react-test-renderer/@types/react": ["@types/react@19.0.14", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw=="], - "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], @@ -2193,8 +2161,6 @@ "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "compressible/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -2387,30 +2353,6 @@ "@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="], - "@react-native/community-cli-plugin/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], - - "@react-native/community-cli-plugin/metro/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], - - "@react-native/community-cli-plugin/metro/metro-babel-transformer": ["metro-babel-transformer@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.32.0", "nullthrows": "^1.1.1" } }, "sha512-rirY1QMFlA1uxH3ZiNauBninwTioOgwChnRdDcbB4tgRZ+bGX9DiXoh9QdpppiaVKXdJsII932OwWXGGV4+Nlw=="], - - "@react-native/community-cli-plugin/metro/metro-cache": ["metro-cache@0.83.2", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.2" } }, "sha512-Z43IodutUZeIS7OTH+yQFjc59QlFJ6s5OvM8p2AP9alr0+F8UKr8ADzFzoGKoHefZSKGa4bJx7MZJLF6GwPDHQ=="], - - "@react-native/community-cli-plugin/metro/metro-cache-key": ["metro-cache-key@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-3EMG/GkGKYoTaf5RqguGLSWRqGTwO7NQ0qXKmNBjr0y6qD9s3VBXYlwB+MszGtmOKsqE9q3FPrE5Nd9Ipv7rZw=="], - - "@react-native/community-cli-plugin/metro/metro-file-map": ["metro-file-map@0.83.2", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-cMSWnEqZrp/dzZIEd7DEDdk72PXz6w5NOKriJoDN9p1TDQ5nAYrY2lHi8d6mwbcGLoSlWmpPyny9HZYFfPWcGQ=="], - - "@react-native/community-cli-plugin/metro/metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="], - - "@react-native/community-cli-plugin/metro/metro-transform-plugins": ["metro-transform-plugins@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-5WlW25WKPkiJk2yA9d8bMuZrgW7vfA4f4MBb9ZeHbTB3eIAoNN8vS8NENgG/X/90vpTB06X66OBvxhT3nHwP6A=="], - - "@react-native/community-cli-plugin/metro/metro-transform-worker": ["metro-transform-worker@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.83.2", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-minify-terser": "0.83.2", "metro-source-map": "0.83.2", "metro-transform-plugins": "0.83.2", "nullthrows": "^1.1.1" } }, "sha512-G5DsIg+cMZ2KNfrdLnWMvtppb3+Rp1GMyj7Bvd9GgYc/8gRmvq1XVEF9XuO87Shhb03kFhGqMTgZerz3hZ1v4Q=="], - - "@react-native/community-cli-plugin/metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], - - "@react-native/community-cli-plugin/metro-config/metro-cache": ["metro-cache@0.83.2", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.2" } }, "sha512-Z43IodutUZeIS7OTH+yQFjc59QlFJ6s5OvM8p2AP9alr0+F8UKr8ADzFzoGKoHefZSKGa4bJx7MZJLF6GwPDHQ=="], - - "@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="], - "@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "@react-navigation/bottom-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], @@ -2519,10 +2461,6 @@ "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], - "@react-native/community-cli-plugin/metro/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="], - - "@react-native/community-cli-plugin/metro/metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-zvIxnh7U0JQ7vT4quasKsijId3dOAWgq+ip2jF/8TMrPUqQabGrs04L2dd0haQJ+PA+d4VvK/bPOY8X/vL2PWw=="], - "@react-navigation/bottom-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index e8228c86..e2c0ec7f 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -1,11 +1,9 @@ import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; -import { useMemo } from "react"; -import { Platform, TouchableOpacity, View } from "react-native"; - -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { Platform, TouchableOpacity, View } from "react-native"; import { Text } from "./common/Text"; +import { type OptionGroup, PlatformOptionsMenu } from "./PlatformOptionsMenu"; interface Props extends React.ComponentProps { source?: MediaSourceInfo; @@ -20,6 +18,8 @@ export const AudioTrackSelector: React.FC = ({ ...props }) => { const isTv = Platform.isTV; + const [open, setOpen] = useState(false); + const { t } = useTranslation(); const audioStreams = useMemo( () => source?.MediaStreams?.filter((x) => x.Type === "Audio"), @@ -31,7 +31,49 @@ export const AudioTrackSelector: React.FC = ({ [audioStreams, selected], ); - const { t } = useTranslation(); + const optionGroups: OptionGroup[] = useMemo( + () => [ + { + id: "audio-streams", + title: "Audio streams", + options: + audioStreams?.map((audio, idx) => ({ + id: `${audio.Index || idx}`, + type: "radio" as const, + groupId: "audio-streams", + label: audio.DisplayTitle || `Audio Stream ${idx + 1}`, + selected: audio.Index === selected, + })) || [], + }, + ], + [audioStreams, selected], + ); + + const handleOptionSelect = (optionId: string) => { + const selectedStream = audioStreams?.find( + (audio, idx) => `${audio.Index || idx}` === optionId, + ); + if ( + selectedStream && + selectedStream.Index !== null && + selectedStream.Index !== undefined + ) { + onChange(selectedStream.Index); + } + setOpen(false); + }; + + const trigger = ( + + {t("item_card.audio")} + setOpen(true)} + > + {selectedAudioSteam?.DisplayTitle} + + + ); if (isTv) return null; @@ -42,44 +84,21 @@ export const AudioTrackSelector: React.FC = ({ minWidth: 50, }} > - - - - - {t("item_card.audio")} - - - - {selectedAudioSteam?.DisplayTitle} - - - - - - Audio streams - {audioStreams?.map((audio, idx: number) => ( - { - if (audio.Index !== null && audio.Index !== undefined) - onChange(audio.Index); - }} - > - - {audio.DisplayTitle} - - - ))} - - + ); }; diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index d52a6bf6..32be1098 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -1,10 +1,8 @@ -import { Platform, TouchableOpacity, View } from "react-native"; - -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { Platform, TouchableOpacity, View } from "react-native"; import { Text } from "./common/Text"; +import { type OptionGroup, PlatformOptionsMenu } from "./PlatformOptionsMenu"; export type Bitrate = { key: string; @@ -61,6 +59,8 @@ export const BitrateSelector: React.FC = ({ ...props }) => { const isTv = Platform.isTV; + const [open, setOpen] = useState(false); + const { t } = useTranslation(); const sorted = useMemo(() => { if (inverted) @@ -76,7 +76,44 @@ export const BitrateSelector: React.FC = ({ ); }, [inverted]); - const { t } = useTranslation(); + const optionGroups: OptionGroup[] = useMemo( + () => [ + { + id: "bitrates", + title: "Bitrates", + options: sorted.map((bitrate) => ({ + id: bitrate.key, + type: "radio" as const, + groupId: "bitrates", + label: bitrate.key, + selected: bitrate.value === selected?.value, + })), + }, + ], + [sorted, selected], + ); + + const handleOptionSelect = (optionId: string) => { + const selectedBitrate = sorted.find((b) => b.key === optionId); + if (selectedBitrate) { + onChange(selectedBitrate); + } + setOpen(false); + }; + + const trigger = ( + + {t("item_card.quality")} + setOpen(true)} + > + + {BITRATES.find((b) => b.value === selected?.value)?.key} + + + + ); if (isTv) return null; @@ -88,41 +125,21 @@ export const BitrateSelector: React.FC = ({ maxWidth: 200, }} > - - - - - {t("item_card.quality")} - - - - {BITRATES.find((b) => b.value === selected?.value)?.key} - - - - - - Bitrates - {sorted.map((b) => ( - { - onChange(b); - }} - > - {b.key} - - ))} - - + ); }; diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index 3125f654..34860a79 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -2,13 +2,11 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { useCallback, useMemo } from "react"; -import { Platform, TouchableOpacity, View } from "react-native"; - -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - +import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { Platform, TouchableOpacity, View } from "react-native"; import { Text } from "./common/Text"; +import { type OptionGroup, PlatformOptionsMenu } from "./PlatformOptionsMenu"; interface Props extends React.ComponentProps { item: BaseItemDto; @@ -23,7 +21,7 @@ export const MediaSourceSelector: React.FC = ({ ...props }) => { const isTv = Platform.isTV; - + const [open, setOpen] = useState(false); const { t } = useTranslation(); const getDisplayName = useCallback((source: MediaSourceInfo) => { @@ -46,6 +44,46 @@ export const MediaSourceSelector: React.FC = ({ return getDisplayName(selected); }, [selected, getDisplayName]); + const optionGroups: OptionGroup[] = useMemo( + () => [ + { + id: "media-sources", + title: "Media sources", + options: + item.MediaSources?.map((source, idx) => ({ + id: `${source.Id || idx}`, + type: "radio" as const, + groupId: "media-sources", + label: getDisplayName(source), + selected: source.Id === selected?.Id, + })) || [], + }, + ], + [item.MediaSources, selected, getDisplayName], + ); + + const handleOptionSelect = (optionId: string) => { + const selectedSource = item.MediaSources?.find( + (source, idx) => `${source.Id || idx}` === optionId, + ); + if (selectedSource) { + onChange(selectedSource); + } + setOpen(false); + }; + + const trigger = ( + + {t("item_card.video")} + setOpen(true)} + > + {selectedName} + + + ); + if (isTv) return null; return ( @@ -55,41 +93,21 @@ export const MediaSourceSelector: React.FC = ({ minWidth: 50, }} > - - - - - {t("item_card.video")} - - - {selectedName} - - - - - Media sources - {item.MediaSources?.map((source, idx: number) => ( - { - onChange(source); - }} - > - - {getDisplayName(source)} - - - ))} - - + ); }; diff --git a/components/PlatformOptionsMenu.tsx b/components/PlatformOptionsMenu.tsx new file mode 100644 index 00000000..eaae01a7 --- /dev/null +++ b/components/PlatformOptionsMenu.tsx @@ -0,0 +1,477 @@ +import { Ionicons } from "@expo/vector-icons"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import type React from "react"; +import { useCallback, useEffect, useRef } from "react"; +import { + Platform, + StyleSheet, + TouchableOpacity, + View, + type ViewProps, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; + +// Conditional import for Expo UI (iOS only) +let ContextMenu: any = null; +let Host: any = null; +let Button: any = null; +let Picker: any = null; + +if (!Platform.isTV && Platform.OS === "ios") { + try { + const ExpoUI = require("@expo/ui/swift-ui"); + ContextMenu = ExpoUI.ContextMenu; + Host = ExpoUI.Host; + Button = ExpoUI.Button; + Picker = ExpoUI.Picker; + } catch { + console.warn( + "Expo UI not available, falling back to Android implementation", + ); + } +} + +// Core option types +export type OptionType = + | "radio" + | "checkbox" + | "toggle" + | "action" + | "separator"; + +// Base option interface +export interface BaseOption { + id: string; + type: OptionType; + label: string; + disabled?: boolean; + hidden?: boolean; +} + +// Specific option types +export interface RadioOption extends BaseOption { + type: "radio"; + groupId: string; // For grouping radio buttons + selected?: boolean; +} + +export interface CheckboxOption extends BaseOption { + type: "checkbox"; + checked: boolean; +} + +export interface ToggleOption extends BaseOption { + type: "toggle"; + value: boolean; +} + +export interface ActionOption extends BaseOption { + type: "action"; + icon?: string; // Ionicons name +} + +export interface SeparatorOption extends BaseOption { + type: "separator"; + label: ""; // Separator doesn't need a label +} + +// Union type for all options +export type Option = + | RadioOption + | CheckboxOption + | ToggleOption + | ActionOption + | SeparatorOption; + +// Option groups +export interface OptionGroup { + id: string; + title: string; + options: Option[]; +} + +// Component props interface +export interface PlatformOptionsMenuProps extends ViewProps { + // Data + groups: OptionGroup[]; + + // Presentation + trigger: React.ReactNode; // Custom trigger button + title: string; + + // Behavior + open: boolean; + onOpenChange: (open: boolean) => void; + onOptionSelect: (optionId: string, value?: any) => void; + + // Platform specific configurations + expoUIConfig?: { + hostStyle?: ViewProps["style"]; + }; + + bottomSheetConfig?: { + snapPoints?: string[]; + enableDynamicSizing?: boolean; + enablePanDownToClose?: boolean; + }; +} + +// Helper component for Android bottom sheet option rendering +const AndroidOptionItem: React.FC<{ + option: Option; + onSelect: (optionId: string, value?: any) => void; + isLast?: boolean; +}> = ({ option, onSelect, isLast }) => { + if (option.hidden) return null; + + if (option.type === "separator") { + return ( + + ); + } + + const handlePress = () => { + if (option.disabled) return; + + switch (option.type) { + case "radio": + onSelect(option.id, !option.selected); + break; + case "checkbox": + onSelect(option.id, !option.checked); + break; + case "toggle": + onSelect(option.id, !option.value); + break; + case "action": + onSelect(option.id); + break; + } + }; + + const renderIcon = () => { + switch (option.type) { + case "radio": + return ( + + ); + case "checkbox": + return ( + + ); + case "toggle": + return ( + + + + ); + case "action": + return option.icon ? ( + + ) : null; + default: + return null; + } + }; + + return ( + <> + + {option.label} + {renderIcon()} + + {!isLast && ( + + )} + + ); +}; + +// Helper component for Android bottom sheet group rendering +const AndroidOptionGroup: React.FC<{ + group: OptionGroup; + onSelect: (optionId: string, value?: any) => void; + isLast?: boolean; +}> = ({ group, onSelect, isLast }) => { + const visibleOptions = group.options.filter((option) => !option.hidden); + + if (visibleOptions.length === 0) return null; + + return ( + + + {group.title} + + + {visibleOptions.map((option, index) => ( + + ))} + + + ); +}; + +/** + * PlatformOptionsMenu Component + * + * A unified component that renders platform-appropriate option menus: + * - iOS: Expo UI ContextMenu with native SwiftUI integration + * - Android: Bottom sheet modal + * + * Supports radio buttons, checkboxes, toggles, actions, and separators. + */ +export const PlatformOptionsMenu: React.FC = ({ + groups, + trigger, + title, + open, + onOpenChange, + onOptionSelect, + expoUIConfig, + bottomSheetConfig, + ...viewProps +}) => { + const isIOS = Platform.OS === "ios"; + const isTv = Platform.isTV; + const bottomSheetModalRef = useRef(null); + const insets = useSafeAreaInsets(); + + // Bottom sheet effects + useEffect(() => { + if (!isIOS) { + if (open) bottomSheetModalRef.current?.present(); + else bottomSheetModalRef.current?.dismiss(); + } + }, [open, isIOS]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index === -1) { + onOpenChange(false); + } + }, + [onOpenChange], + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + if (isTv) return null; + + // iOS Implementation with Expo UI ContextMenu + if (isIOS && ContextMenu && Host && Button) { + const renderContextMenuItems = () => { + const items: React.ReactNode[] = []; + + groups.forEach((group) => { + // Add group items + group.options.forEach((option) => { + if (option.hidden) return; + + if (option.type === "separator") { + return; + } + + if (option.type === "radio") { + // For radio options, create a picker if multiple options in the same group + const groupOptions = groups + .flatMap((g) => g.options) + .filter( + (opt) => + opt.type === "radio" && + (opt as RadioOption).groupId === + (option as RadioOption).groupId, + ); + + if (groupOptions.length > 1) { + // Create a picker for radio group + const selectedIndex = groupOptions.findIndex( + (opt) => (opt as RadioOption).selected, + ); + const pickerOptions = groupOptions.map((opt) => opt.label); + + items.push( + { + const selectedOption = groupOptions[index]; + if (selectedOption) { + onOptionSelect(selectedOption.id, true); + } + }} + />, + ); + return; // Skip individual radio buttons when we have a picker + } + } + + // For other option types, create buttons + const systemImage = + option.type === "action" && (option as ActionOption).icon + ? (option as ActionOption).icon + : undefined; + + const variant = (() => { + if (option.type === "checkbox") { + return (option as CheckboxOption).checked ? "filled" : "bordered"; + } + if (option.type === "toggle") { + return (option as ToggleOption).value ? "filled" : "bordered"; + } + return "bordered"; + })(); + + items.push( + , + ); + }); + }); + + return items; + }; + + return ( + + + + {renderContextMenuItems()} + {trigger} + + + + ); + } + + // Android Implementation with Bottom Sheet + return ( + + + + + {title} + + {groups.map((group, index) => ( + + ))} + + + + + ); +}; diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index de3f4697..4e97c422 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -1,12 +1,10 @@ import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Platform, TouchableOpacity, View } from "react-native"; import { tc } from "@/utils/textTools"; - -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - -import { useTranslation } from "react-i18next"; import { Text } from "./common/Text"; +import { type OptionGroup, PlatformOptionsMenu } from "./PlatformOptionsMenu"; interface Props extends React.ComponentProps { source?: MediaSourceInfo; @@ -21,6 +19,8 @@ export const SubtitleTrackSelector: React.FC = ({ ...props }) => { const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const subtitleStreams = useMemo(() => { return source?.MediaStreams?.filter((x) => x.Type === "Subtitle"); }, [source]); @@ -30,6 +30,69 @@ export const SubtitleTrackSelector: React.FC = ({ [subtitleStreams, selected], ); + const optionGroups: OptionGroup[] = useMemo(() => { + const options = [ + { + id: "none", + type: "radio" as const, + groupId: "subtitle-streams", + label: t("item_card.none"), + selected: selected === -1, + }, + ...(subtitleStreams?.map((subtitle, idx) => ({ + id: `${subtitle.Index || idx}`, + type: "radio" as const, + groupId: "subtitle-streams", + label: subtitle.DisplayTitle || `Subtitle Stream ${idx + 1}`, + selected: subtitle.Index === selected, + })) || []), + ]; + + return [ + { + id: "subtitle-streams", + title: "Subtitle tracks", + options, + }, + ]; + }, [subtitleStreams, selected, t]); + + const handleOptionSelect = (optionId: string) => { + if (optionId === "none") { + onChange(-1); + } else { + const selectedStream = subtitleStreams?.find( + (subtitle, idx) => `${subtitle.Index || idx}` === optionId, + ); + if ( + selectedStream && + selectedStream.Index !== undefined && + selectedStream.Index !== null + ) { + onChange(selectedStream.Index); + } + } + setOpen(false); + }; + + const trigger = ( + + + {t("item_card.subtitles")} + + setOpen(true)} + > + + {selectedSubtitleSteam + ? tc(selectedSubtitleSteam?.DisplayTitle, 7) + : t("item_card.none")} + + + + ); + if (Platform.isTV || subtitleStreams?.length === 0) return null; return ( @@ -40,54 +103,21 @@ export const SubtitleTrackSelector: React.FC = ({ maxWidth: 200, }} > - - - - - {t("item_card.subtitles")} - - - - {selectedSubtitleSteam - ? tc(selectedSubtitleSteam?.DisplayTitle, 7) - : t("item_card.none")} - - - - - - Subtitle tracks - { - onChange(-1); - }} - > - None - - {subtitleStreams?.map((subtitle, idx: number) => ( - { - if (subtitle.Index !== undefined && subtitle.Index !== null) - onChange(subtitle.Index); - }} - > - - {subtitle.DisplayTitle} - - - ))} - - + ); }; diff --git a/components/common/Dropdown.tsx b/components/common/Dropdown.tsx index 3ec4d5ce..bcf698c0 100644 --- a/components/common/Dropdown.tsx +++ b/components/common/Dropdown.tsx @@ -1,14 +1,14 @@ -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - 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, PlatformOptionsMenu } from "../PlatformOptionsMenu"; interface Props { data: T[]; @@ -35,7 +35,7 @@ const Dropdown = ({ ...props }: PropsWithChildren & ViewProps>) => { const isTv = Platform.isTV; - + const [open, setOpen] = useState(false); const [selected, setSelected] = useState(); useEffect(() => { @@ -44,80 +44,97 @@ const Dropdown = ({ } }, [selected, onSelected]); + const optionGroups: OptionGroup[] = useMemo( + () => [ + { + id: "dropdown-items", + title: label, + options: data.map((item) => { + const key = keyExtractor(item); + const isSelected = + selected?.some((s) => keyExtractor(s) === key) || false; + + return { + id: key, + type: multiple ? "checkbox" : ("radio" as const), + groupId: "dropdown-items", + label: titleExtractor(item) || key, + ...(multiple ? { checked: isSelected } : { selected: isSelected }), + }; + }), + }, + ], + [data, selected, multiple, keyExtractor, titleExtractor, label], + ); + + 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 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 ( - - - {typeof title === "string" ? ( - - {title} - - - {selected?.length !== undefined - ? selected.map(titleExtractor).join(",") - : placeholderText} - - - - ) : ( - title - )} - - - {label} - {data.map((item, _idx) => - multiple ? ( - keyExtractor(s) === keyExtractor(item)) - ? "on" - : "off" - } - key={keyExtractor(item)} - onValueChange={( - next: "on" | "off", - _previous: "on" | "off", - ) => { - setSelected((p) => { - const prev = p || []; - if (next === "on") { - return [...prev, item]; - } - return [ - ...prev.filter( - (p) => keyExtractor(p) !== keyExtractor(item), - ), - ]; - }); - }} - > - - {titleExtractor(item)} - - - ) : ( - setSelected([item])} - > - - {titleExtractor(item)} - - - ), - )} - - + ); }; diff --git a/components/common/JellyseerrItemRouter.tsx b/components/common/JellyseerrItemRouter.tsx index 8a0b059a..2fdaa801 100644 --- a/components/common/JellyseerrItemRouter.tsx +++ b/components/common/JellyseerrItemRouter.tsx @@ -1,14 +1,8 @@ import { useRouter, useSegments } from "expo-router"; import type React from "react"; -import { type PropsWithChildren, useCallback, useMemo } from "react"; +import { type PropsWithChildren } from "react"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; -import * as ContextMenu from "zeego/context-menu"; -import { useJellyseerr } from "@/hooks/useJellyseerr"; import { MediaType } from "@/utils/jellyseerr/server/constants/media"; -import { - hasPermission, - Permission, -} from "@/utils/jellyseerr/server/lib/permissions"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; import type { @@ -38,90 +32,33 @@ export const TouchableJellyseerrRouter: React.FC> = ({ }) => { const router = useRouter(); const segments = useSegments(); - const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); const from = (segments as string[])[2] || "(home)"; - const autoApprove = useMemo(() => { - return ( - jellyseerrUser && - hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, { - type: "or", - }) - ); - }, [jellyseerrApi, jellyseerrUser]); - - const request = useCallback(() => { - if (!result) return; - requestMedia(mediaTitle, { - mediaId: result.id, - mediaType, - }); - }, [jellyseerrApi, result]); - if (from === "(home)" || from === "(search)" || from === "(libraries)") return ( - - - { - if (!result) return; + { + if (!result) return; - router.push({ - pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, - // @ts-expect-error - params: { - ...result, - mediaTitle, - releaseYear, - canRequest: canRequest.toString(), - posterSrc, - mediaType, - }, - }); - }} - {...props} - > - {children} - - - - Actions - {canRequest && mediaType === MediaType.MOVIE && ( - { - if (autoApprove) { - request(); - } - }} - shouldDismissMenuOnSelect - > - - Request - - - - )} - - + router.push({ + pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, + // @ts-expect-error + params: { + ...result, + mediaTitle, + releaseYear, + canRequest: canRequest.toString(), + posterSrc, + mediaType, + }, + }); + }} + {...props} + > + {children} + ); + + return null; }; diff --git a/components/series/SeasonDropdown.tsx b/components/series/SeasonDropdown.tsx index e9b4f0e7..cb1d51e9 100644 --- a/components/series/SeasonDropdown.tsx +++ b/components/series/SeasonDropdown.tsx @@ -1,11 +1,9 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useEffect, useMemo } from "react"; -import { Platform, TouchableOpacity, View } from "react-native"; - -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - import { t } from "i18next"; +import { useEffect, useMemo, useState } from "react"; +import { Platform, TouchableOpacity, View } from "react-native"; import { Text } from "../common/Text"; +import { type OptionGroup, PlatformOptionsMenu } from "../PlatformOptionsMenu"; type Props = { item: BaseItemDto; @@ -33,6 +31,7 @@ export const SeasonDropdown: React.FC = ({ onSelect, }) => { const isTv = Platform.isTV; + const [open, setOpen] = useState(false); const keys = useMemo( () => @@ -55,6 +54,43 @@ export const SeasonDropdown: React.FC = ({ [state, item, keys], ); + const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => + Number(a[keys.index]) - Number(b[keys.index]); + + const optionGroups: OptionGroup[] = useMemo( + () => [ + { + id: "seasons", + title: t("item_card.seasons"), + options: + seasons?.sort(sortByIndex).map((season: any) => { + const title = + season[keys.title] || + season.Name || + `Season ${season.IndexNumber}`; + return { + id: `${season.Id || season.IndexNumber}`, + type: "radio" as const, + groupId: "seasons", + label: title, + selected: Number(season[keys.index]) === Number(seasonIndex), + }; + }) || [], + }, + ], + [seasons, keys, seasonIndex], + ); + + const handleSeasonSelect = (optionId: string) => { + const selectedSeason = seasons?.find( + (season: any) => `${season.Id || season.IndexNumber}` === optionId, + ); + if (selectedSeason) { + onSelect(selectedSeason); + } + setOpen(false); + }; + useEffect(() => { if (isTv) return; if (seasons && seasons.length > 0 && seasonIndex === undefined) { @@ -96,45 +132,36 @@ export const SeasonDropdown: React.FC = ({ keys, ]); - const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => - Number(a[keys.index]) - Number(b[keys.index]); + const trigger = ( + + setOpen(true)} + > + + {t("item_card.season")} {seasonIndex} + + + + ); if (isTv) return null; return ( - - - - - - {t("item_card.season")} {seasonIndex} - - - - - - {t("item_card.seasons")} - {seasons?.sort(sortByIndex).map((season: any) => { - const title = - season[keys.title] || season.Name || `Season ${season.IndexNumber}`; - return ( - onSelect(season)} - > - {title} - - ); - })} - - + ); }; diff --git a/components/settings/AppLanguageSelector.tsx b/components/settings/AppLanguageSelector.tsx index f77bc2c8..0aefe3ee 100644 --- a/components/settings/AppLanguageSelector.tsx +++ b/components/settings/AppLanguageSelector.tsx @@ -1,5 +1,4 @@ -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Platform, TouchableOpacity, View, type ViewProps } from "react-native"; import { APP_LANGUAGES } from "@/i18n"; @@ -7,6 +6,7 @@ import { useSettings } from "@/utils/atoms/settings"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; +import { type OptionGroup, PlatformOptionsMenu } from "../PlatformOptionsMenu"; interface Props extends ViewProps {} @@ -14,6 +14,55 @@ export const AppLanguageSelector: React.FC = () => { const isTv = Platform.isTV; const { settings, updateSettings } = useSettings(); const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const optionGroups: OptionGroup[] = useMemo(() => { + const options = [ + { + id: "system", + type: "radio" as const, + groupId: "languages", + label: t("home.settings.languages.system"), + selected: !settings?.preferedLanguage, + }, + ...APP_LANGUAGES.map((lang) => ({ + id: lang.value, + type: "radio" as const, + groupId: "languages", + label: lang.label, + selected: lang.value === settings?.preferedLanguage, + })), + ]; + + return [ + { + id: "languages", + title: t("home.settings.languages.title"), + options, + }, + ]; + }, [settings?.preferedLanguage, t]); + + const handleOptionSelect = (optionId: string) => { + if (optionId === "system") { + updateSettings({ preferedLanguage: undefined }); + } else { + updateSettings({ preferedLanguage: optionId }); + } + setOpen(false); + }; + + const trigger = ( + setOpen(true)} + > + + {APP_LANGUAGES.find((l) => l.value === settings?.preferedLanguage) + ?.label || t("home.settings.languages.system")} + + + ); if (isTv) return null; if (!settings) return null; @@ -22,54 +71,21 @@ export const AppLanguageSelector: React.FC = () => { - - - - - {APP_LANGUAGES.find( - (l) => l.value === settings?.preferedLanguage, - )?.label || t("home.settings.languages.system")} - - - - - - {t("home.settings.languages.title")} - - { - updateSettings({ - preferedLanguage: undefined, - }); - }} - > - - {t("home.settings.languages.system")} - - - {APP_LANGUAGES?.map((l) => ( - { - updateSettings({ - preferedLanguage: l.value, - }); - }} - > - {l.label} - - ))} - - + diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx index 82ff1157..966311a1 100644 --- a/components/settings/AudioToggles.tsx +++ b/components/settings/AudioToggles.tsx @@ -1,20 +1,20 @@ -import { Platform, TouchableOpacity, View, type ViewProps } from "react-native"; - -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - import { Ionicons } from "@expo/vector-icons"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { Platform, TouchableOpacity, View, type ViewProps } from "react-native"; import { Switch } from "react-native-gesture-handler"; import { useSettings } from "@/utils/atoms/settings"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; +import { type OptionGroup, PlatformOptionsMenu } from "../PlatformOptionsMenu"; import { useMedia } from "./MediaContext"; interface Props extends ViewProps {} export const AudioToggles: React.FC = ({ ...props }) => { const isTv = Platform.isTV; + const [open, setOpen] = useState(false); const media = useMedia(); const { pluginSettings } = useSettings(); @@ -22,6 +22,70 @@ export const AudioToggles: React.FC = ({ ...props }) => { const cultures = media.cultures; const { t } = useTranslation(); + const optionGroups: OptionGroup[] = useMemo(() => { + const options = [ + { + id: "none", + type: "radio" as const, + groupId: "audio-languages", + label: t("home.settings.audio.none"), + selected: !settings?.defaultAudioLanguage, + }, + ...(cultures?.map((culture) => ({ + id: + culture.ThreeLetterISOLanguageName || + culture.DisplayName || + "unknown", + type: "radio" as const, + groupId: "audio-languages", + label: + culture.DisplayName || + culture.ThreeLetterISOLanguageName || + "Unknown", + selected: + culture.ThreeLetterISOLanguageName === + settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName, + })) || []), + ]; + + return [ + { + id: "audio-languages", + title: t("home.settings.audio.language"), + options, + }, + ]; + }, [cultures, settings?.defaultAudioLanguage, t]); + + const handleOptionSelect = (optionId: string) => { + if (optionId === "none") { + updateSettings({ defaultAudioLanguage: null }); + } else { + const selectedCulture = cultures?.find( + (culture) => + (culture.ThreeLetterISOLanguageName || culture.DisplayName) === + optionId, + ); + if (selectedCulture) { + updateSettings({ defaultAudioLanguage: selectedCulture }); + } + } + setOpen(false); + }; + + const trigger = ( + setOpen(true)} + > + + {settings?.defaultAudioLanguage?.DisplayName || + t("home.settings.audio.none")} + + + + ); + if (isTv) return null; if (!settings) return null; @@ -48,60 +112,21 @@ export const AudioToggles: React.FC = ({ ...props }) => { /> - - - - - {settings?.defaultAudioLanguage?.DisplayName || - t("home.settings.audio.none")} - - - - - - - {t("home.settings.audio.language")} - - { - updateSettings({ - defaultAudioLanguage: null, - }); - }} - > - - {t("home.settings.audio.none")} - - - {cultures?.map((l) => ( - { - updateSettings({ - defaultAudioLanguage: l, - }); - }} - > - - {l.DisplayName} - - - ))} - - + diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index 59ec1570..e1be30d4 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -1,10 +1,7 @@ -import { Platform, TouchableOpacity, View, type ViewProps } from "react-native"; - -const _DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - import { Ionicons } from "@expo/vector-icons"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; import { useTranslation } from "react-i18next"; +import { Platform, TouchableOpacity, 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"; diff --git a/package.json b/package.json index 6b13ce94..c4db322e 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,11 @@ "@bottom-tabs/react-navigation": "^0.11.2", "@expo/metro-runtime": "~6.1.1", "@expo/react-native-action-sheet": "^4.1.1", + "@expo/ui": "~0.2.0-beta.4", "@expo/vector-icons": "^15.0.2", "@gorhom/bottom-sheet": "^5.1.0", "@jellyfin/sdk": "^0.11.0", - "@kesha-antonov/react-native-background-downloader": "github:kesha-antonov/react-native-background-downloader#main", + "@kesha-antonov/react-native-background-downloader": "github:fredrikburmester/react-native-background-downloader#f3bf69ad124b6ec6adbc30c7f688935d0376fc56", "@react-native-community/netinfo": "^11.4.1", "@react-native-menu/menu": "^1.2.4", "@react-navigation/material-top-tabs": "^7.2.14", @@ -82,8 +83,8 @@ "react-native-google-cast": "^4.9.0", "react-native-image-colors": "^2.4.0", "react-native-ios-context-menu": "^3.1.0", - "react-native-ios-utilities": "5.1.8", - "react-native-mmkv": "4.0.0-beta.6", + "react-native-ios-utilities": "5.2.0", + "react-native-mmkv": "4.0.0-beta.12", "react-native-nitro-modules": "^0.29.1", "react-native-pager-view": "^6.9.1", "react-native-reanimated": "~4.1.0", @@ -101,7 +102,6 @@ "sonner-native": "^0.21.0", "tailwindcss": "3.3.2", "use-debounce": "^10.0.4", - "zeego": "^3.0.6", "zod": "^4.1.3" }, "devDependencies": {