Compare commits

..

3 Commits

Author SHA1 Message Date
Gauvain
f99400876b fix(player): keep controls visible while settings popover is open
Re-enabling the auto-hide timeout (previous commit) let it fire while the
player settings popover was open, dismissing it mid-interaction — most
visibly on iOS, where the popover lives inside the controls and closes
when they fade out.

Surface the popover's open state up through DropdownView -> HeaderControls
-> Controls and pass it as useControlsTimeout's `disabled` flag, so the
auto-hide pauses while the menu is open and re-arms once it closes. iOS
reports open/close via the SwiftUI Popover binding; Android's menu is a
separate global bottom-sheet modal and is unaffected by controls hiding.
2026-06-01 23:36:42 +02:00
Gauvain
de5e323db4 fix(player): re-enable controls auto-hide timeout
The useControlsTimeout call hardcoded disabled: true (introduced in
the KSPlayer PR #1266), so player controls never auto-hid after the
inactivity timeout and stayed on screen until manually toggled.
Remove the override to restore the intended auto-hide behavior.
2026-06-01 23:32:43 +02:00
Alex Kim
fef1e7f122 feat(video-player): Update in player controls to new UI 2026-05-31 23:53:12 +10:00
16 changed files with 1293 additions and 365 deletions

View File

@@ -6,7 +6,6 @@ import {
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
@@ -77,7 +76,7 @@ const MobilePage: React.FC = () => {
const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
const advancedReqModalRef = useRef<BottomSheetModalMethods>(null);
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const {

120
bun.lock
View File

@@ -103,7 +103,7 @@
"@biomejs/biome": "2.4.16",
"@react-native-community/cli": "20.1.3",
"@react-native-tvos/config-tv": "0.1.6",
"@types/jest": "30.0.0",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.24",
"@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",
@@ -379,17 +379,11 @@
"@jellyfin/sdk": ["@jellyfin/sdk@0.13.0", "", { "peerDependencies": { "axios": "^1.12.0" } }, "sha512-oiBAOXH6s+dKdReSsYgNktBDzbxtg4JVWhEzIxZSxKcWMdSKmBtK41MhXRO7IWAC40DguKUm3nU/Z493qPAlWA=="],
"@jest/diff-sequences": ["@jest/diff-sequences@30.4.0", "", {}, "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g=="],
"@jest/expect-utils": ["@jest/expect-utils@29.7.0", "", { "dependencies": { "jest-get-type": "^29.6.3" } }, "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA=="],
"@jest/expect-utils": ["@jest/expect-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ=="],
"@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="],
"@jest/pattern": ["@jest/pattern@30.4.0", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.4.0" } }, "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg=="],
"@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="],
"@jest/types": ["@jest/types@30.4.1", "", { "dependencies": { "@jest/pattern": "30.4.0", "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ=="],
"@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="],
"@jimp/bmp": ["@jimp/bmp@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "bmp-js": "^0.1.0" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g=="],
@@ -561,7 +555,7 @@
"@sideway/pinpoint": ["@sideway/pinpoint@2.0.0", "", {}, "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
"@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="],
"@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="],
@@ -605,7 +599,7 @@
"@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="],
"@types/jest": ["@types/jest@30.0.0", "", { "dependencies": { "expect": "^30.0.0", "pretty-format": "^30.0.0" } }, "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA=="],
"@types/jest": ["@types/jest@29.5.14", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ=="],
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
@@ -873,6 +867,8 @@
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
@@ -939,7 +935,7 @@
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
"expect": ["expect@30.4.1", "", { "dependencies": { "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.4.1", "jest-message-util": "30.4.1", "jest-mock": "30.4.1", "jest-util": "30.4.1" } }, "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA=="],
"expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="],
"expo": ["expo@56.0.6", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^56.1.12", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/devtools": "~56.0.2", "@expo/dom-webview": "~56.0.5", "@expo/fingerprint": "^0.19.3", "@expo/local-build-cache-provider": "^56.0.8", "@expo/log-box": "^56.0.12", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.13", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~56.0.13", "expo-asset": "~56.0.15", "expo-constants": "~56.0.16", "expo-file-system": "~56.0.7", "expo-font": "~56.0.5", "expo-keep-awake": "~56.0.3", "expo-modules-autolinking": "~56.0.14", "expo-modules-core": "~56.0.13", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/metro-runtime", "react-dom", "react-native-web", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-zcFa/+6hGtzCUlcrGiusvzr/PIoNBAnjj4PlAFrvbAOZcVOj6c9Mp7lRSn9XYJk8Ok6pssQWt6dP4llJlKmYRQ=="],
@@ -1199,19 +1195,15 @@
"jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
"jest-diff": ["jest-diff@30.4.1", "", { "dependencies": { "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.4.1" } }, "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA=="],
"jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="],
"jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="],
"jest-matcher-utils": ["jest-matcher-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.4.1", "pretty-format": "30.4.1" } }, "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A=="],
"jest-matcher-utils": ["jest-matcher-utils@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.7.0", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g=="],
"jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="],
"jest-message-util": ["jest-message-util@29.7.0", "", { "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "micromatch": "^4.0.4", "pretty-format": "^29.7.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" } }, "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w=="],
"jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="],
"jest-regex-util": ["jest-regex-util@30.4.0", "", {}, "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg=="],
"jest-util": ["jest-util@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw=="],
"jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="],
"jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="],
@@ -1505,7 +1497,7 @@
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="],
"pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="],
@@ -1551,10 +1543,6 @@
"react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="],
"react-is-18": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-is-19": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="],
"react-native": ["react-native-tvos@0.85.3-0", "", { "dependencies": { "@react-native-tvos/virtualized-lists": "0.85.3-0", "@react-native/assets-registry": "0.85.3", "@react-native/codegen": "0.85.3", "@react-native/community-cli-plugin": "0.85.3", "@react-native/gradle-plugin": "0.85.3", "@react-native/js-polyfills": "0.85.3", "@react-native/normalize-colors": "0.85.3", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-plugin-syntax-hermes-parser": "0.33.3", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "hermes-compiler": "250829098.0.10", "invariant": "^2.2.4", "memoize-one": "^5.0.0", "metro-runtime": "^0.84.3", "metro-source-map": "^0.84.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "tinyglobby": "^0.2.15", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@react-native/jest-preset": "0.85.3", "@types/react": "^19.1.1", "react": "^19.2.3" }, "optionalPeers": ["@react-native/jest-preset", "@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-Q9gUndppXbGEiYlQ8eudkdH7rDXdY+KM74Btd5xqMvXHgo7ZXdwI1hKvStmI47KmTaDn0NOmcRl2yBwHfc5+5A=="],
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
@@ -1947,8 +1935,6 @@
"@expo/cli/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/cli/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"@expo/cli/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"@expo/cli/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=="],
@@ -1989,16 +1975,12 @@
"@expo/metro-config/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"@expo/metro-runtime/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"@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.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"@expo/require-utils/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
"@jest/pattern/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"@jest/types/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"@jimp/png/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
@@ -2015,8 +1997,6 @@
"@react-native-community/cli-server-api/open": ["open@6.4.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg=="],
"@react-native-community/cli-server-api/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"@react-native-community/cli-server-api/ws": ["ws@6.2.4", "", { "dependencies": { "async-limiter": "~1.0.0" } }, "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw=="],
"@react-native-community/cli-tools/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
@@ -2071,8 +2051,6 @@
"error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"expo/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"expo-build-properties/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
@@ -2105,20 +2083,12 @@
"jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"jest-mock/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"jest-util/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"jest-util/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"jest-validate/@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="],
"jest-validate/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"jest-worker/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"jest-worker/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="],
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -2177,14 +2147,14 @@
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"react-native/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"react-native/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"react-native-drawer-layout/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
@@ -2247,16 +2217,8 @@
"@expo/cli/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
"@expo/cli/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"@expo/cli/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"@expo/cli/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@expo/metro-runtime/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"@expo/metro-runtime/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"@expo/package-manager/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
"@expo/package-manager/ora/cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="],
@@ -2265,16 +2227,10 @@
"@expo/package-manager/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
"@jest/pattern/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"@jest/types/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
"@react-native-community/cli-server-api/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"@react-native-community/cli-server-api/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"@react-navigation/elements/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=="],
@@ -2309,30 +2265,12 @@
"expo-router/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=="],
"expo/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"expo/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"jest-mock/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"jest-util/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"jest-validate/@jest/types/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"jest-validate/@jest/types/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"jest-validate/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"jest-validate/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"jest-worker/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"jest-worker/jest-util/@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="],
"jest-worker/jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
@@ -2381,10 +2319,6 @@
"react-native-drawer-layout/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=="],
"react-native/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"react-native/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -2405,12 +2339,8 @@
"@expo/cli/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
"@expo/cli/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="],
"@expo/cli/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"@expo/metro-runtime/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="],
"@expo/package-manager/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"@expo/package-manager/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -2421,8 +2351,6 @@
"@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
"@react-native-community/cli-server-api/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="],
"@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/elements/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -2443,16 +2371,6 @@
"expo-router/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"expo/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="],
"jest-validate/@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="],
"jest-validate/@jest/types/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"jest-validate/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="],
"jest-worker/jest-util/@jest/types/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
@@ -2475,8 +2393,6 @@
"react-native-drawer-layout/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"react-native/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="],
"@expo/cli/ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"@expo/cli/ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
@@ -2495,8 +2411,6 @@
"cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"jest-worker/jest-util/@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="],
"logkitty/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],

View File

@@ -1,14 +1,13 @@
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect, useState } from "react";
import {
type LayoutChangeEvent,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import React, { useEffect } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
MeasuredTriggerHost,
OptionGroupCard,
ToggleSwitch,
} from "@/components/common/dropdownShared";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -16,7 +15,7 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider";
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
// load and crashes the entire route tree on tvOS (expo-router requires every
// route file). Load it lazily and only off-TV; TV never renders these.
const { Button, Host, Menu } = Platform.isTV
const { Button, Menu } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const { disabled } = Platform.isTV
@@ -72,16 +71,6 @@ interface PlatformDropdownProps {
};
}
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
<View
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
/>
</View>
);
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
option,
isLast,
@@ -121,28 +110,15 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
};
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
<View className='mb-6'>
{group.title && (
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
{group.title}
</Text>
)}
<View
style={{
borderRadius: 12,
overflow: "hidden",
}}
className='bg-neutral-800 rounded-xl overflow-hidden'
>
{group.options.map((option, index) => (
<OptionItem
key={index}
option={option}
isLast={index === group.options.length - 1}
/>
))}
</View>
</View>
<OptionGroupCard title={group.title}>
{group.options.map((option, index) => (
<OptionItem
key={index}
option={option}
isLast={index === group.options.length - 1}
/>
))}
</OptionGroupCard>
);
const BottomSheetContent: React.FC<{
@@ -217,24 +193,6 @@ const PlatformDropdownComponent = ({
}: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
// `matchContents` doesn't help here: it reports the native Menu's size via
// setStyleSize and overrides any explicit size. Instead we measure the
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
const [triggerSize, setTriggerSize] = useState<{
width: number;
height: number;
} | null>(null);
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setTriggerSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
};
// Handle controlled open state for Android
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) {
@@ -265,82 +223,42 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios" && !Platform.isTV) {
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
// fills its parent and reports its own size via setStyleSize, so it can't
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
// height depends on the parent while the parent depends on the Host — a
// circular dependency that collapses to 0 for any selector nested more than
// one level deep (so only the first, shallowest dropdown stays visible).
// Giving the wrapper the measured size breaks the cycle; the Host then
// fills a concrete box.
return (
<View style={triggerSize ?? { opacity: 0 }}>
{/* Hidden measurer: lays the trigger out off-flow to capture its
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
sizes to the trigger's content rather than to its parent. */}
<View
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
pointerEvents='none'
aria-hidden
onLayout={handleMeasureTrigger}
>
{trigger}
</View>
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
<Menu label={trigger}>
{groups.flatMap((group, groupIndex) => {
// Check if this group has radio options
const radioOptions = group.options.filter(
(opt) => opt.type === "radio",
) as RadioOption[];
const toggleOptions = group.options.filter(
(opt) => opt.type === "toggle",
) as ToggleOption[];
const actionOptions = group.options.filter(
(opt) => opt.type === "action",
) as ActionOption[];
<MeasuredTriggerHost
trigger={trigger}
hostStyle={expoUIConfig?.hostStyle}
>
<Menu label={trigger}>
{groups.flatMap((group, groupIndex) => {
// Check if this group has radio options
const radioOptions = group.options.filter(
(opt) => opt.type === "radio",
) as RadioOption[];
const toggleOptions = group.options.filter(
(opt) => opt.type === "toggle",
) as ToggleOption[];
const actionOptions = group.options.filter(
(opt) => opt.type === "action",
) as ActionOption[];
const items = [];
const items = [];
// Group radio options under a submenu ONLY if there's a title
// Otherwise render as individual buttons
if (radioOptions.length > 0) {
if (group.title) {
// Use a nested Menu as a submenu for grouped options. This
// reads as "Title: Selected" and expands to the choices on
// tap, keeping the nested look while staying a dropdown.
// (Menu opens on a single tap and nests cleanly; ContextMenu
// would require a long-press and read as a context menu.)
const selectedOption = radioOptions.find(
(opt) => opt.selected,
);
const displayTitle = selectedOption
? `${group.title}: ${selectedOption.label}`
: group.title;
items.push(
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
{radioOptions.map((option, optionIndex) => (
<Button
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
modifiers={
option.disabled ? [disabled(true)] : undefined
}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
/>
))}
</Menu>,
);
} else {
// Render radio options as direct buttons
radioOptions.forEach((option, optionIndex) => {
items.push(
// Group radio options under a submenu ONLY if there's a title
// Otherwise render as individual buttons
if (radioOptions.length > 0) {
if (group.title) {
// Use a nested Menu as a submenu for grouped options. This
// reads as "Title: Selected" and expands to the choices on
// tap, keeping the nested look while staying a dropdown.
// (Menu opens on a single tap and nests cleanly; ContextMenu
// would require a long-press and read as a context menu.)
const selectedOption = radioOptions.find((opt) => opt.selected);
const displayTitle = selectedOption
? `${group.title}: ${selectedOption.label}`
: group.title;
items.push(
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
{radioOptions.map((option, optionIndex) => (
<Button
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
@@ -354,49 +272,67 @@ const PlatformDropdownComponent = ({
option.onPress();
onOptionSelect?.(option.value);
}}
/>,
);
});
}
/>
))}
</Menu>,
);
} else {
// Render radio options as direct buttons
radioOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
/>,
);
});
}
}
// Add Buttons for toggle options
toggleOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`toggle-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.value ? "checkmark.circle.fill" : "circle"
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onToggle();
onOptionSelect?.(option.value);
}}
/>,
);
});
// Add Buttons for toggle options
toggleOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`toggle-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.value ? "checkmark.circle.fill" : "circle"
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onToggle();
onOptionSelect?.(option.value);
}}
/>,
);
});
// Add Buttons for action options (no icon)
actionOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`action-${groupIndex}-${optionIndex}`}
label={option.label}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
}}
/>,
);
});
// Add Buttons for action options (no icon)
actionOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`action-${groupIndex}-${optionIndex}`}
label={option.label}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
}}
/>,
);
});
return items;
})}
</Menu>
</Host>
</View>
return items;
})}
</Menu>
</MeasuredTriggerHost>
);
}

View File

@@ -74,9 +74,6 @@ function ChapterListComponent({
transparent
animationType='slide'
onRequestClose={onClose}
// iOS defaults <Modal> to portrait-only; without this it rotates the app
// back to portrait when opened from the landscape player. Android ignores it.
supportedOrientations={["portrait", "landscape"]}
>
<Pressable onPress={onClose} style={styles.backdrop}>
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>

View File

@@ -2,7 +2,6 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Platform,
TouchableOpacity,
@@ -150,7 +149,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
children,
...props
}) => {
const { t } = useTranslation();
const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]);
@@ -184,13 +182,11 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
return;
const options: string[] = [
t("common.mark_as_played"),
t("common.mark_as_not_played"),
isFavorite
? t("music.track_options.remove_from_favorites")
: t("music.track_options.add_to_favorites"),
...(isOffline ? [t("home.downloads.delete_download")] : []),
t("common.cancel"),
"Mark as Played",
"Mark as Not Played",
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
...(isOffline ? ["Delete Download"] : []),
"Cancel",
];
const cancelButtonIndex = options.length - 1;
const destructiveButtonIndex = isOffline
@@ -223,7 +219,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
isOffline,
deleteFile,
item.Id,
t,
]);
if (

View File

@@ -0,0 +1,142 @@
// Shared internals for PlatformDropdown and PlayerSettingsPopover.
// Both components host SwiftUI content (Menu / Popover) inside @expo/ui's
// <Host>, both render an Android bottom-sheet card for the same three core
// option types (radio / toggle / action), and both wear the same wrapper
// boilerplate. This module is the single source of truth for those pieces.
//
// What lives here:
// - useTriggerSize() — measures the RN trigger's intrinsic size
// - MeasuredTriggerHost — pins <Host> to that measured size (workaround
// for @expo/ui SDK 55 sizing behaviour; see notes below)
// - ToggleSwitch — the small purple switch used in the Android sheet
// - OptionGroupCard — the rounded dark card with optional title that
// wraps a group's option rows on Android
//
// What deliberately doesn't live here:
// - The iOS rendering — PlatformDropdown uses a Menu, PlayerSettingsPopover
// uses a hand-styled Popover. Nothing meaningful to share.
// - The Android per-row renderers — PlatformDropdown handles 3 option types,
// PlayerSettingsPopover handles 6 (adds slider/stepper/subgroup). Forcing
// a shared abstraction would couple them. Each owns its own OptionItem.
import React, { useCallback, useState } from "react";
import {
type LayoutChangeEvent,
Platform,
StyleSheet,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
// load and crashes the entire route tree on tvOS. Load it lazily and only
// off-TV; both consumers also gate rendering on Platform.OS === "ios".
const { Host } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
type TriggerSize = { width: number; height: number };
/**
* Measures and remembers the intrinsic size of a RN trigger view so the
* surrounding <Host> can be pinned to a concrete box.
*
* Returns `[size, handleLayout]` — pass `handleLayout` to a hidden,
* absolutely-positioned mirror of the trigger and use `size` as the
* wrapper's `style` once measured.
*/
export function useTriggerSize(): [
TriggerSize | null,
(e: LayoutChangeEvent) => void,
] {
const [size, setSize] = useState<TriggerSize | null>(null);
const onLayout = useCallback((e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
}, []);
return [size, onLayout];
}
interface MeasuredTriggerHostProps {
trigger: React.ReactNode;
hostStyle?: any;
children: React.ReactNode;
}
/**
* Pins @expo/ui's <Host> to the trigger's measured size.
*
* @expo/ui's <Host> (SDK 55) fills its parent and reports its own size via
* `setStyleSize`, so it can't size itself to content. If the wrapper has no
* size, the Host's `flex: 1` height depends on the parent while the parent
* depends on the Host — a circular dependency that collapses to 0 for any
* dropdown nested more than one level deep (so only the first, shallowest
* dropdown on screen stays visible).
*
* Giving the wrapper the measured trigger size breaks the cycle; the Host
* then fills a concrete box.
*/
export const MeasuredTriggerHost: React.FC<MeasuredTriggerHostProps> = ({
trigger,
hostStyle,
children,
}) => {
const [size, handleMeasure] = useTriggerSize();
return (
<View style={size ?? { opacity: 0 }}>
{/* Hidden measurer: lays the trigger out off-flow to capture its
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
sizes to the trigger's content rather than to its parent. */}
<View
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
pointerEvents='none'
aria-hidden
onLayout={handleMeasure}
>
{trigger}
</View>
<Host style={[StyleSheet.absoluteFill, hostStyle as any]}>
{children}
</Host>
</View>
);
};
/** Small pill switch used by Android sheet rows. */
export const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
<View
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
/>
</View>
);
/**
* Rounded dark card with an optional title above it. Wraps a group's option
* rows in the Android bottom sheet.
*/
export const OptionGroupCard: React.FC<{
title?: string;
children: React.ReactNode;
}> = ({ title, children }) => (
<View className='mb-6'>
{title && (
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
{title}
</Text>
)}
<View
style={{ borderRadius: 12, overflow: "hidden" }}
className='bg-neutral-800 rounded-xl overflow-hidden'
>
{children}
</View>
</View>
);

View File

@@ -37,20 +37,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
return { ...item, People: people } as BaseItemDto;
}, [item, people]);
// Jellyfin can list the same person several times (e.g. an actor also
// credited as writer). Dedupe by Id so the same actor section isn't rendered
// twice and we still surface 3 distinct people.
const topPeople = useMemo(() => {
const seen = new Set<string>();
const unique: BaseItemPerson[] = [];
for (const person of people) {
if (!person.Id || seen.has(person.Id)) continue;
seen.add(person.Id);
unique.push(person);
if (unique.length >= 3) break;
}
return unique;
}, [people]);
const topPeople = useMemo(() => people.slice(0, 3), [people]);
const renderActorSection = useCallback(
(person: BaseItemPerson, idx: number, total: number) => {

View File

@@ -110,6 +110,10 @@ export const Controls: FC<Props> = ({
const [episodeView, setEpisodeView] = useState(false);
const [showAudioSlider, setShowAudioSlider] = useState(false);
// Pause the controls auto-hide while the player settings popover is open so
// it can't be dismissed out from under the user (notably the iOS popover,
// which lives inside the controls and closes when they fade out).
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const { previousItem, nextItem } = usePlaybackManager({
@@ -467,7 +471,7 @@ export const Controls: FC<Props> = ({
episodeView,
onHideControls: hideControls,
timeout: CONTROLS_CONSTANTS.TIMEOUT,
disabled: true,
disabled: settingsMenuOpen,
});
const switchOnEpisodeMode = useCallback(() => {
@@ -528,6 +532,7 @@ export const Controls: FC<Props> = ({
setPlaybackSpeed={setPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={onToggleTechnicalInfo}
onSettingsMenuOpenChange={setSettingsMenuOpen}
/>
</Animated.View>
<Animated.View

View File

@@ -37,6 +37,9 @@ interface HeaderControlsProps {
// Technical info props
showTechnicalInfo?: boolean;
onToggleTechnicalInfo?: () => void;
// Notifies the parent when the settings popover opens/closes so it can pause
// the controls auto-hide while the menu is up.
onSettingsMenuOpenChange?: (open: boolean) => void;
}
export const HeaderControls: FC<HeaderControlsProps> = ({
@@ -57,6 +60,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
setPlaybackSpeed,
showTechnicalInfo = false,
onToggleTechnicalInfo,
onSettingsMenuOpenChange,
}) => {
const { settings } = useSettings();
const router = useRouter();
@@ -117,6 +121,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
setPlaybackSpeed={setPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={onToggleTechnicalInfo}
onOpenChange={onSettingsMenuOpenChange}
/>
</View>
)}

View File

@@ -3,10 +3,6 @@ import { useLocalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef } from "react";
import { Platform, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import {
type OptionGroup,
PlatformDropdown,
} from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import useRouter from "@/hooks/useAppRouter";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
@@ -14,26 +10,18 @@ import { useSettings } from "@/utils/atoms/settings";
import { usePlayerContext } from "../contexts/PlayerContext";
import { useVideoContext } from "../contexts/VideoContext";
import { PlaybackSpeedScope } from "../utils/playback-speed-settings";
// Subtitle scale presets (direct multiplier values)
const SUBTITLE_SCALE_PRESETS = [
{ label: "0.1x", value: 0.1 },
{ label: "0.25x", value: 0.25 },
{ label: "0.5x", value: 0.5 },
{ label: "0.75x", value: 0.75 },
{ label: "1.0x", value: 1.0 },
{ label: "1.25x", value: 1.25 },
{ label: "1.5x", value: 1.5 },
{ label: "2.0x", value: 2.0 },
{ label: "2.5x", value: 2.5 },
{ label: "3.0x", value: 3.0 },
] as const;
import {
type OptionGroup,
PlayerSettingsPopover,
} from "./PlayerSettingsPopover";
interface DropdownViewProps {
playbackSpeed?: number;
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
showTechnicalInfo?: boolean;
onToggleTechnicalInfo?: () => void;
/** Forwarded to the popover so the player can pause auto-hide while it's open. */
onOpenChange?: (open: boolean) => void;
}
const DropdownView = ({
@@ -41,6 +29,7 @@ const DropdownView = ({
setPlaybackSpeed,
showTechnicalInfo = false,
onToggleTechnicalInfo,
onOpenChange,
}: DropdownViewProps) => {
const { subtitleTracks, audioTracks } = useVideoContext();
const { item, mediaSource } = usePlayerContext();
@@ -102,6 +91,7 @@ const DropdownView = ({
if (!isOffline) {
groups.push({
title: "Quality",
icon: "gauge.with.dots.needle.50percent",
options:
BITRATES?.map((bitrate) => ({
type: "radio" as const,
@@ -113,29 +103,41 @@ const DropdownView = ({
});
}
// Subtitle Section
// Subtitles section. iOS: tap the `...` opens a SwiftUI Popover with the
// section header "SUBTITLES" + a Track row (Menu) + a Size row (native
// Slider). Android: same shape in a bottom-sheet — tap the "Track" row to
// expand the list inline, Size shows a Material 3 Slider.
if (subtitleTracks && subtitleTracks.length > 0) {
groups.push({
title: "Subtitles",
options: subtitleTracks.map((sub) => ({
type: "radio" as const,
label: sub.name,
value: sub.index.toString(),
selected: subtitleIndex === sub.index.toString(),
onPress: () => sub.setTrack(),
})),
});
// Subtitle Scale Section
groups.push({
title: "Subtitle Scale",
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
type: "radio" as const,
label: preset.label,
value: preset.value.toString(),
selected: (settings.mpvSubtitleScale ?? 1.0) === preset.value,
onPress: () => updateSettings({ mpvSubtitleScale: preset.value }),
})),
options: [
{
type: "subgroup" as const,
label: "Track",
icon: "captions.bubble",
options: subtitleTracks.map((sub) => ({
type: "radio" as const,
label: sub.name,
value: sub.index.toString(),
selected: subtitleIndex === sub.index.toString(),
onPress: () => sub.setTrack(),
})),
},
{
type: "slider" as const,
label: "Size",
icon: "textformat.size",
value: Math.round((settings.mpvSubtitleScale ?? 1.0) * 10) / 10,
step: 0.1,
min: 0.1,
max: 3.0,
format: (v: number) => `${v.toFixed(1)}x`,
onValueChange: (value: number) =>
updateSettings({
mpvSubtitleScale: Math.round(value * 10) / 10,
}),
},
],
});
}
@@ -143,6 +145,7 @@ const DropdownView = ({
if (audioTracks && audioTracks.length > 0) {
groups.push({
title: "Audio",
icon: "speaker.wave.2",
options: audioTracks.map((track) => ({
type: "radio" as const,
label: track.name,
@@ -157,6 +160,7 @@ const DropdownView = ({
if (setPlaybackSpeed) {
groups.push({
title: "Speed",
icon: "speedometer",
options: PLAYBACK_SPEEDS.map((speed) => ({
type: "radio" as const,
label: speed.label,
@@ -176,6 +180,7 @@ const DropdownView = ({
label: showTechnicalInfo
? "Hide Technical Info"
: "Show Technical Info",
icon: "info.circle",
onPress: onToggleTechnicalInfo,
},
],
@@ -216,11 +221,12 @@ const DropdownView = ({
if (Platform.isTV) return null;
return (
<PlatformDropdown
<PlayerSettingsPopover
title='Playback Options'
groups={optionGroups}
trigger={trigger}
expoUIConfig={{}}
onOpenChange={onOpenChange}
bottomSheetConfig={{
enablePanDownToClose: true,
}}

View File

@@ -0,0 +1,930 @@
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect, useState } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
MeasuredTriggerHost,
OptionGroupCard,
ToggleSwitch,
} from "@/components/common/dropdownShared";
import { Text } from "@/components/common/Text";
import type {
ActionOption as BaseActionOption,
RadioOption as BaseRadioOption,
ToggleOption as BaseToggleOption,
} from "@/components/PlatformDropdown";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
// Player-only popover/sheet. Shares no rendering with `PlatformDropdown`:
// that component is used by ~20 callers (settings, season pickers,
// bitrate/audio/subtitle selectors, …) and must keep its small native
// Menu look. This one targets the in-player `...` button and is allowed to
// (a) host a real slider, (b) wear the Swift-mock visual style, and
// (c) carry SF Symbol icons per row.
//
// Common boilerplate (trigger measurement, ToggleSwitch, Android option-card
// shell) lives in @/components/common/dropdownShared and is reused with
// PlatformDropdown.
//
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
// load and crashes the entire route tree on tvOS (expo-router requires every
// route file). Load it lazily and only off-TV; TV never renders these.
const {
Button,
HStack,
Image: SwiftImage,
Menu,
Popover,
Rectangle: SwiftRectangle,
Slider: SwiftSlider,
Spacer,
Stepper,
Text: SwiftText,
Toggle: SwiftToggle,
VStack,
} = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const {
buttonStyle,
disabled,
font,
foregroundStyle,
frame,
opacity,
padding,
tint,
} = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
: require("@expo/ui/swift-ui/modifiers");
// Android-side Material 3 slider. Lives in @expo/ui/community/slider and is a
// drop-in for react-native-community/slider on Android (and SwiftUI Slider on
// iOS, but we use the swift-ui Slider directly inside the popover instead).
const { Slider: CommunitySlider } = Platform.isTV
? ({} as typeof import("@expo/ui/community/slider"))
: require("@expo/ui/community/slider");
// ---------------------------------------------------------------------------
// Option model
// ---------------------------------------------------------------------------
// Reuses PlatformDropdown's three base option types (so the 20+ shared callers
// and the player popover stay in sync on shape), then adds:
// - `icon?: string` on every variant — SF Symbol shown in the iOS popover
// - Slider / Stepper / Subgroup variants for the player's extra controls
type WithIcon = { icon?: string };
export type RadioOption<T = any> = BaseRadioOption<T> & WithIcon;
export type ToggleOption = BaseToggleOption & WithIcon;
export type ActionOption = BaseActionOption & WithIcon;
export type StepperOption = {
type: "stepper";
label: string;
value: number;
step: number;
min: number;
max: number;
onValueChange: (value: number) => void;
/** Optional value formatter for the displayed number. */
format?: (value: number) => string;
disabled?: boolean;
} & WithIcon;
export type SliderOption = {
type: "slider";
label: string;
value: number;
step: number;
min: number;
max: number;
onValueChange: (value: number) => void;
/** Optional value formatter for the displayed number. */
format?: (value: number) => string;
disabled?: boolean;
} & WithIcon;
/**
* A row that itself opens a nested dropdown. On iOS this renders as a
* SwiftUI `Menu` inside the popover (label = subgroup name, value =
* currently-selected child); on Android the row expands inline to show its
* options when tapped (and collapses again on a second tap).
*/
export type SubgroupOption = {
type: "subgroup";
label: string;
options: Option[];
disabled?: boolean;
} & WithIcon;
export type Option =
| RadioOption
| ToggleOption
| ActionOption
| StepperOption
| SliderOption
| SubgroupOption;
export type OptionGroup = {
title?: string;
options: Option[];
/**
* Optional SF Symbol used for the group's row in the iOS popover when the
* entire group is compressed to a single Menu (e.g. radio-only groups).
*/
icon?: string;
};
interface PlayerSettingsPopoverProps {
trigger?: React.ReactNode;
title?: string;
groups: OptionGroup[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onOptionSelect?: (value?: any) => void;
expoUIConfig?: {
hostStyle?: any;
};
bottomSheetConfig?: {
enableDynamicSizing?: boolean;
enablePanDownToClose?: boolean;
};
}
// ---------------------------------------------------------------------------
// Android bottom-sheet renderers
// ---------------------------------------------------------------------------
const StepperControl: React.FC<{
option: StepperOption;
}> = ({ option }) => {
const display = option.format
? option.format(option.value)
: option.value.toString();
const canDecrement = option.value > option.min;
const canIncrement = option.value < option.max;
const decrement = () => {
if (option.disabled) return;
const next = Math.max(option.min, option.value - option.step);
if (next !== option.value) option.onValueChange(next);
};
const increment = () => {
if (option.disabled) return;
const next = Math.min(option.max, option.value + option.step);
if (next !== option.value) option.onValueChange(next);
};
return (
<View className='flex flex-row items-center'>
<TouchableOpacity
onPress={decrement}
disabled={!canDecrement || option.disabled}
className={`w-8 h-8 bg-neutral-700 rounded-l-lg flex items-center justify-center ${!canDecrement || option.disabled ? "opacity-40" : ""}`}
>
<Text className='text-white'>-</Text>
</TouchableOpacity>
<View className='h-8 px-3 bg-neutral-700 flex items-center justify-center'>
<Text className='text-white'>{display}</Text>
</View>
<TouchableOpacity
onPress={increment}
disabled={!canIncrement || option.disabled}
className={`w-8 h-8 bg-neutral-700 rounded-r-lg flex items-center justify-center ${!canIncrement || option.disabled ? "opacity-40" : ""}`}
>
<Text className='text-white'>+</Text>
</TouchableOpacity>
</View>
);
};
/**
* Android: full-width Material 3 slider inside the bottom sheet, with a
* label/value row above the track. The slider lives below the touch target so
* dragging it doesn't accidentally collapse the sheet.
*/
const SliderControl: React.FC<{
option: SliderOption;
}> = ({ option }) => {
const display = option.format
? option.format(option.value)
: option.value.toString();
return (
<View className='flex-1 px-4 py-3'>
<View className='flex flex-row items-center justify-between mb-2'>
<Text className='text-white'>{option.label}</Text>
<Text className='text-neutral-400'>{display}</Text>
</View>
<CommunitySlider
value={option.value}
minimumValue={option.min}
maximumValue={option.max}
step={option.step}
onValueChange={option.onValueChange}
disabled={option.disabled}
style={{ width: "100%", height: 40 }}
/>
</View>
);
};
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
option,
isLast,
}) => {
const [expanded, setExpanded] = useState(false);
const isToggle = option.type === "toggle";
const isAction = option.type === "action";
const isStepper = option.type === "stepper";
const isSlider = option.type === "slider";
const isSubgroup = option.type === "subgroup";
if (isSlider) {
return (
<>
<SliderControl option={option} />
{!isLast && (
<View
style={{ height: StyleSheet.hairlineWidth }}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
}
const handlePress = isToggle
? option.onToggle
: isSubgroup
? () => setExpanded((v) => !v)
: isStepper
? undefined
: (option as RadioOption | ActionOption).onPress;
const selectedChild = isSubgroup
? (option.options.find(
(o): o is RadioOption => o.type === "radio" && o.selected,
) ?? undefined)
: undefined;
return (
<>
<TouchableOpacity
onPress={handlePress}
disabled={option.disabled || isStepper}
activeOpacity={isStepper ? 1 : 0.2}
className={`px-4 py-3 flex flex-row items-center justify-between ${option.disabled ? "opacity-50" : ""}`}
>
<Text className='flex-1 text-white'>{option.label}</Text>
{isToggle ? (
<ToggleSwitch value={option.value} />
) : isStepper ? (
<StepperControl option={option} />
) : isSubgroup ? (
<View className='flex flex-row items-center'>
{selectedChild && (
<Text className='text-neutral-400 mr-2'>
{selectedChild.label}
</Text>
)}
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={20}
color='#9ca3af'
/>
</View>
) : isAction ? null : (option as RadioOption).selected ? (
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
) : (
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
)}
</TouchableOpacity>
{isSubgroup && expanded && (
<View className='pl-4 bg-neutral-900'>
{option.options.map((child, childIndex) => (
<OptionItem
key={childIndex}
option={child}
isLast={childIndex === option.options.length - 1}
/>
))}
</View>
)}
{!isLast && (
<View
style={{ height: StyleSheet.hairlineWidth }}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
};
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
<OptionGroupCard title={group.title}>
{group.options.map((option, index) => (
<OptionItem
key={index}
option={option}
isLast={index === group.options.length - 1}
/>
))}
</OptionGroupCard>
);
const BottomSheetContent: React.FC<{
title?: string;
groups: OptionGroup[];
onOptionSelect?: (value?: any) => void;
onClose?: () => void;
}> = ({ title, groups, onOptionSelect, onClose }) => {
const insets = useSafeAreaInsets();
// Recursively wrap options so radio/action presses also call
// onOptionSelect/onClose, including options nested inside subgroups.
const wrapOption = (option: Option): Option => {
if (option.type === "radio") {
return {
...option,
onPress: () => {
option.onPress();
onOptionSelect?.(option.value);
onClose?.();
},
};
}
if (option.type === "toggle") {
return {
...option,
onToggle: () => {
option.onToggle();
onOptionSelect?.(option.value);
},
};
}
if (option.type === "action") {
return {
...option,
onPress: () => {
option.onPress();
onClose?.();
},
};
}
if (option.type === "subgroup") {
return { ...option, options: option.options.map(wrapOption) };
}
return option;
};
const wrappedGroups = groups.map((group) => ({
...group,
options: group.options.map(wrapOption),
}));
return (
<BottomSheetScrollView
className='px-4 pb-8 pt-2'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
{title && <Text className='font-bold text-2xl mb-6'>{title}</Text>}
{wrappedGroups.map((group, index) => (
<OptionGroupComponent key={index} group={group} />
))}
</BottomSheetScrollView>
);
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const PlayerSettingsPopoverComponent = ({
trigger,
title,
groups,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
onOptionSelect,
expoUIConfig,
bottomSheetConfig,
}: PlayerSettingsPopoverProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
// Android: controlled open routes through the global bottom-sheet modal.
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) {
showModal(
<BottomSheetContent
title={title}
groups={groups}
onOptionSelect={onOptionSelect}
onClose={() => {
hideModal();
controlledOnOpenChange?.(false);
}}
/>,
{
snapPoints: ["90%"],
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
},
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlledOpen]);
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
controlledOnOpenChange?.(false);
}
}, [isVisible, controlledOpen, controlledOnOpenChange]);
// Internal open state for the iOS popover. Synced both ways with
// `controlledOpen` when controlled.
const [iosOpen, setIosOpen] = useState(false);
useEffect(() => {
if (Platform.OS === "ios" && controlledOpen !== undefined) {
setIosOpen(controlledOpen);
}
}, [controlledOpen]);
const handleIosOpenChange = (value: boolean) => {
setIosOpen(value);
controlledOnOpenChange?.(value);
};
if (Platform.OS === "ios" && !Platform.isTV) {
const closePopover = () => handleIosOpenChange(false);
// ---- Swift-mock styled popover body ----
// Mirrors the reference Swift `PlayerSettingsViewController` design:
// - small-caps section headers with a hairline rule to the trailing edge
// - 44pt rows with leading SF Symbol, 15pt title, trailing value + glyph
// - real native Slider rows for slider options
// Radio-only titled groups (Quality/Audio/Speed) are compressed to a
// single Menu row whose label is a styled HStack — tapping opens the
// selection menu without changing the panel's height.
type IconName = string | undefined;
const MENU_CHEVRON = "chevron.up.chevron.down" as const;
const TERTIARY = {
type: "hierarchical" as const,
style: "tertiary" as const,
};
const SECONDARY = {
type: "hierarchical" as const,
style: "secondary" as const,
};
/** 24pt-wide leading icon slot. Renders a transparent placeholder when
* no icon is set so titles stay aligned across rows. */
const renderIcon = (icon: IconName) => (
<SwiftImage
systemName={(icon ?? "circle") as any}
size={18}
modifiers={[
frame({ width: 24, alignment: "leading" }),
foregroundStyle(SECONDARY),
...(icon ? [] : [opacity(0)]),
]}
/>
);
/** Small-caps section header + thin separator that fills the row width. */
const renderSectionHeader = (sectionTitle: string, key: string) => (
<HStack
key={key}
spacing={10}
alignment='center'
modifiers={[frame({ height: 28 })]}
>
<SwiftText
modifiers={[
font({ size: 11, weight: "semibold" }),
foregroundStyle(TERTIARY),
]}
>
{sectionTitle.toUpperCase()}
</SwiftText>
<SwiftRectangle
modifiers={[frame({ height: 1 }), foregroundStyle(TERTIARY)]}
/>
</HStack>
);
/** Bare hairline used to close out a multi-row titled section. */
const renderDivider = (key: string) => (
<SwiftRectangle
key={key}
modifiers={[
frame({ height: 1 }),
foregroundStyle(TERTIARY),
padding({ vertical: 2 }),
]}
/>
);
/** Render menu-safe children (radio/action) inside a SwiftUI Menu. */
const renderMenuChild = (option: Option, key: string): any => {
if (option.type === "radio") {
return (
<Button
key={key}
label={option.label}
systemImage={
(option.selected ? "checkmark.circle.fill" : "circle") as any
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
closePopover();
}}
/>
);
}
if (option.type === "action") {
return (
<Button
key={key}
label={option.label}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
closePopover();
}}
/>
);
}
return null;
};
/** Row that opens a SwiftUI Menu on tap. Used for compressed radio
* groups and for subgroup options inside a multi-row section. */
const renderMenuRow = ({
key,
icon,
title: rowTitle,
valueLabel,
children,
}: {
key: string;
icon: IconName;
title: string;
valueLabel?: string;
children: any;
}) => (
<Menu
key={key}
label={
<HStack
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(icon)}
<SwiftText modifiers={[font({ size: 15 })]}>{rowTitle}</SwiftText>
<Spacer />
{valueLabel ? (
<SwiftText
modifiers={[font({ size: 13 }), foregroundStyle(SECONDARY)]}
>
{valueLabel}
</SwiftText>
) : null}
<SwiftImage
systemName={MENU_CHEVRON as any}
size={12}
modifiers={[foregroundStyle(TERTIARY)]}
/>
</HStack>
}
>
{children}
</Menu>
);
const renderSliderRow = (option: SliderOption, key: string) => {
const display = option.format
? option.format(option.value)
: option.value.toString();
return (
<HStack
key={key}
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText
modifiers={[
font({ size: 15 }),
frame({ width: 64, alignment: "leading" }),
]}
>
{option.label}
</SwiftText>
<SwiftSlider
value={option.value}
min={option.min}
max={option.max}
step={option.step}
modifiers={option.disabled ? [disabled(true)] : undefined}
onValueChange={option.onValueChange}
/>
<SwiftText
modifiers={[
font({ size: 13, design: "monospaced" }),
foregroundStyle(SECONDARY),
frame({ width: 44, alignment: "trailing" }),
]}
>
{display}
</SwiftText>
</HStack>
);
};
const renderStepperRow = (option: StepperOption, key: string) => {
const display = option.format
? option.format(option.value)
: option.value.toString();
return (
<HStack
key={key}
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
<Spacer />
<Stepper
label={display}
value={option.value}
step={option.step}
min={option.min}
max={option.max}
modifiers={option.disabled ? [disabled(true)] : undefined}
onValueChange={option.onValueChange}
/>
</HStack>
);
};
const renderToggleRow = (option: ToggleOption, key: string) => (
<HStack
key={key}
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
<Spacer />
<SwiftToggle
label=''
value={option.value}
modifiers={option.disabled ? [disabled(true)] : undefined}
onValueChange={() => {
option.onToggle();
onOptionSelect?.(option.value);
}}
/>
</HStack>
);
const renderActionRow = (option: ActionOption, key: string) => (
<Button
key={key}
modifiers={[
buttonStyle("plain"),
...(option.disabled ? [disabled(true)] : []),
]}
onPress={() => {
option.onPress();
closePopover();
}}
>
<HStack
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
<Spacer />
</HStack>
</Button>
);
/** Render one Option as its own row inside a mixed (non-compressed)
* section. */
const renderOptionRow = (option: Option, key: string): any => {
if (option.type === "slider") return renderSliderRow(option, key);
if (option.type === "stepper") return renderStepperRow(option, key);
if (option.type === "toggle") return renderToggleRow(option, key);
if (option.type === "action") return renderActionRow(option, key);
if (option.type === "subgroup") {
const selectedChild = option.options.find(
(o): o is RadioOption => o.type === "radio" && o.selected,
);
return renderMenuRow({
key,
icon: option.icon,
title: option.label,
valueLabel: selectedChild?.label,
children: option.options.map((child, idx) =>
renderMenuChild(child, `${key}-c${idx}`),
),
});
}
if (option.type === "radio") {
return (
<Button
key={key}
modifiers={[
buttonStyle("plain"),
...(option.disabled ? [disabled(true)] : []),
]}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
closePopover();
}}
>
<HStack
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText modifiers={[font({ size: 15 })]}>
{option.label}
</SwiftText>
<Spacer />
{option.selected ? (
<SwiftImage
systemName={"checkmark" as any}
size={14}
modifiers={[foregroundStyle(SECONDARY)]}
/>
) : null}
</HStack>
</Button>
);
}
return null;
};
/**
* Render an entire OptionGroup.
* - Titled group with only radio (or radio + action) options →
* compressed to a single Menu row.
* - Titled group containing slider/toggle/stepper/subgroup →
* section header + individual rows.
* - Untitled group → individual rows, no header.
*/
const renderGroup = (group: OptionGroup, groupIndex: number): any[] => {
if (group.options.length === 0) return [];
const onlyMenuSafe = group.options.every(
(o) => o.type === "radio" || o.type === "action",
);
if (group.title && onlyMenuSafe) {
const selectedRadio = group.options.find(
(o): o is RadioOption => o.type === "radio" && o.selected,
);
return [
renderMenuRow({
key: `group-${groupIndex}`,
icon: group.icon,
title: group.title,
valueLabel: selectedRadio?.label,
children: group.options.map((opt, idx) =>
renderMenuChild(opt, `g${groupIndex}-c${idx}`),
),
}),
];
}
const rows: any[] = [];
if (group.title) {
rows.push(renderSectionHeader(group.title, `header-${groupIndex}`));
}
group.options.forEach((opt, idx) => {
rows.push(renderOptionRow(opt, `g${groupIndex}-o${idx}`));
});
return rows;
};
return (
<MeasuredTriggerHost
trigger={trigger}
hostStyle={expoUIConfig?.hostStyle}
>
<Popover
isPresented={iosOpen}
onIsPresentedChange={handleIosOpenChange}
arrowEdge='top'
>
<Popover.Trigger>
{/* Wrap the RN trigger view in a SwiftUI Button so tap handling
is captured at the SwiftUI layer (matches the codebase
pattern in SearchTabButtons.tsx). */}
<Button
modifiers={[buttonStyle("plain")]}
onPress={() => handleIosOpenChange(true)}
>
{trigger}
</Button>
</Popover.Trigger>
<Popover.Content>
{/* Bare VStack — no Form/List chrome — so the panel reads as
the Swift mock's floating glass card. The popover itself
supplies the material background; we just stack rows
inside. Width pinned to ~320pt; height >= 480pt. */}
<VStack
spacing={0}
alignment='leading'
modifiers={[
padding({ horizontal: 18, top: 12, bottom: 12 }),
frame({
minWidth: 300,
idealWidth: 320,
maxWidth: 360,
minHeight: 480,
idealHeight: 520,
}),
// Tint cascades to all child controls — Slider track, Menu
// checkmark, Stepper ± buttons, Toggle — so one modifier
// paints the whole popover white instead of system blue.
tint("white"),
]}
>
{groups.flatMap((group, groupIndex) => {
const rows = renderGroup(group, groupIndex);
if (rows.length === 0) return [];
// After a multi-row titled section (Subtitles), append a
// bare hairline divider so it's clearly separated from
// the next group below.
const isMultiRow =
!!group.title &&
!group.options.every(
(o) => o.type === "radio" || o.type === "action",
);
const hasNext = groupIndex < groups.length - 1;
return isMultiRow && hasNext
? [...rows, renderDivider(`footer-${groupIndex}`)]
: rows;
})}
</VStack>
</Popover.Content>
</Popover>
</MeasuredTriggerHost>
);
}
// Android: open the bottom sheet directly on press (uncontrolled mode).
const handlePress = () => {
showModal(
<BottomSheetContent
title={title}
groups={groups}
onOptionSelect={onOptionSelect}
onClose={hideModal}
/>,
{
snapPoints: ["90%"],
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
},
);
};
return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</TouchableOpacity>
);
};
// Memoize to prevent unnecessary re-renders when parent re-renders.
export const PlayerSettingsPopover = React.memo(
PlayerSettingsPopoverComponent,
(prevProps, nextProps) =>
prevProps.title === nextProps.title &&
prevProps.open === nextProps.open &&
prevProps.groups === nextProps.groups &&
prevProps.trigger === nextProps.trigger,
);

View File

@@ -1,20 +1,46 @@
apply plugin: 'expo-module-gradle-plugin'
plugins {
id 'com.android.library'
id 'kotlin-android'
}
group = 'expo.modules.backgrounddownloader'
version = '1.0.0'
expoModule {
canBePublished false
}
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useDefaultAndroidSdkVersions()
useCoreDependencies()
useExpoPublishing()
android {
namespace "expo.modules.backgrounddownloader"
defaultConfig {
versionCode 1
versionName "1.0.0"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
lintOptions {
abortOnError false
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "com.squareup.okhttp3:okhttp:4.12.0"
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
jvmTarget = "17"
}
}

View File

@@ -124,7 +124,7 @@
"@biomejs/biome": "2.4.16",
"@react-native-community/cli": "20.1.3",
"@react-native-tvos/config-tv": "0.1.6",
"@types/jest": "30.0.0",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.24",
"@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",

View File

@@ -69,13 +69,6 @@ const initialApi = (() => {
const initialUser = (() => {
try {
// Only return a stored user if we also have a token. Otherwise the
// user atom would be populated while the api atom is null (e.g. after
// a logout that left stale user JSON in storage), which causes
// useProtectedRoute to keep us inside the (auth) group instead of
// redirecting to /login.
const token = storage.getString("token");
if (!token) return null;
const userStr = storage.getString("user");
if (userStr) {
return JSON.parse(userStr) as UserDto;
@@ -409,7 +402,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
);
storage.remove("token");
storage.remove("user");
clearTVDiscoverySafely();
setUser(null);
setApi(null);

View File

@@ -456,7 +456,6 @@
"new_app_version_requires_re_download_description": "Die neue App-Version erfordert das erneute Herunterladen von Filmen und Serien. Bitte lösche alle heruntergeladenen Elemente und starte den Download erneut.",
"back": "Zurück",
"delete": "Löschen",
"delete_download": "Download löschen",
"something_went_wrong": "Etwas ist schiefgelaufen",
"could_not_get_stream_url_from_jellyfin": "Konnte keine Stream-URL von Jellyfin erhalten",
"eta": "ETA {{eta}}",
@@ -499,8 +498,6 @@
"audio": "Audio",
"subtitle": "Untertitel",
"play": "Abspielen",
"mark_as_played": "Als gesehen markieren",
"mark_as_not_played": "Als ungesehen markieren",
"none": "Keine",
"track": "Spur",
"cancel": "Abbrechen",

View File

@@ -534,7 +534,6 @@
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
"back": "Back",
"delete": "Delete",
"delete_download": "Delete Download",
"something_went_wrong": "Something Went Wrong",
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
"eta": "ETA {{eta}}",
@@ -578,8 +577,6 @@
"audio": "Audio",
"subtitle": "Subtitle",
"play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",