mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-17 14:31:58 +01:00
Compare commits
8 Commits
fix/github
...
feat/andro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16bb1b8717 | ||
|
|
259306df52 | ||
|
|
631a5ef94e | ||
|
|
8b8b928837 | ||
|
|
56a3c62ed2 | ||
|
|
82683407da | ||
|
|
7b146e30bd | ||
|
|
5f48bec0f2 |
2
.github/workflows/build-apps.yml
vendored
2
.github/workflows/build-apps.yml
vendored
@@ -192,7 +192,7 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@v1
|
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||||
with:
|
with:
|
||||||
xcode-version: "26.0.1"
|
xcode-version: "26.0.1"
|
||||||
|
|
||||||
|
|||||||
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.47.1",
|
"version": "0.48.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 84,
|
"versionCode": 85,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -20,7 +21,11 @@ const Page: React.FC = () => {
|
|||||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||||
const isOffline = offline === "true";
|
const isOffline = offline === "true";
|
||||||
|
|
||||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
const { data: item, isError } = useItemQuery(id, false, undefined, [
|
||||||
|
ItemFields.MediaSources,
|
||||||
|
ItemFields.MediaSourceCount,
|
||||||
|
ItemFields.MediaStreams,
|
||||||
|
]);
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { useDownload } from "@/providers/DownloadProvider";
|
|||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||||
@@ -673,7 +674,30 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
console.log("Debug: component render"); // Uncomment to debug re-renders
|
// Prepare metadata for iOS native media controls
|
||||||
|
const nowPlayingMetadata = useMemo(() => {
|
||||||
|
if (!item || !api) return undefined;
|
||||||
|
|
||||||
|
const artworkUri = getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: item.Name || "",
|
||||||
|
artist:
|
||||||
|
item.Type === "Episode"
|
||||||
|
? item.SeriesName || ""
|
||||||
|
: item.AlbumArtist || "",
|
||||||
|
albumTitle:
|
||||||
|
item.Type === "Episode" && item.SeasonName
|
||||||
|
? item.SeasonName
|
||||||
|
: undefined,
|
||||||
|
artworkUri: artworkUri || undefined,
|
||||||
|
};
|
||||||
|
}, [item, api]);
|
||||||
|
|
||||||
// Show error UI first, before checking loading/missing‐data
|
// Show error UI first, before checking loading/missing‐data
|
||||||
if (itemStatus.isError || streamStatus.isError) {
|
if (itemStatus.isError || streamStatus.isError) {
|
||||||
@@ -731,6 +755,7 @@ export default function page() {
|
|||||||
initOptions,
|
initOptions,
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
nowPlayingMetadata={nowPlayingMetadata}
|
||||||
onVideoProgress={onProgress}
|
onVideoProgress={onProgress}
|
||||||
progressUpdateInterval={1000}
|
progressUpdateInterval={1000}
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
onVideoStateChange={onPlaybackStateChanged}
|
||||||
|
|||||||
166
bun.lock
166
bun.lock
@@ -50,7 +50,7 @@
|
|||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "16.0.0",
|
||||||
"react-native": "npm:react-native-tvos@0.81.5-1",
|
"react-native": "npm:react-native-tvos@0.81.5-1",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "^1.0.2",
|
"react-native-bottom-tabs": "^1.0.2",
|
||||||
@@ -85,20 +85,20 @@
|
|||||||
"zod": "^4.1.3",
|
"zod": "^4.1.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
"@babel/core": "7.28.5",
|
||||||
"@biomejs/biome": "^2.3.5",
|
"@biomejs/biome": "2.3.5",
|
||||||
"@react-native-community/cli": "^20.0.2",
|
"@react-native-community/cli": "20.0.2",
|
||||||
"@react-native-tvos/config-tv": "^0.1.4",
|
"@react-native-tvos/config-tv": "0.1.4",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "^4.17.20",
|
"@types/lodash": "4.17.20",
|
||||||
"@types/react": "~19.1.10",
|
"@types/react": "~19.1.10",
|
||||||
"@types/react-test-renderer": "^19.0.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "^1.17.11",
|
"expo-doctor": "1.17.11",
|
||||||
"husky": "^9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "^16.2.6",
|
"lint-staged": "16.2.6",
|
||||||
"react-test-renderer": "19.1.1",
|
"react-test-renderer": "19.1.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -405,19 +405,13 @@
|
|||||||
|
|
||||||
"@jest/create-cache-key-function": ["@jest/create-cache-key-function@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3" } }, "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA=="],
|
"@jest/create-cache-key-function": ["@jest/create-cache-key-function@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3" } }, "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA=="],
|
||||||
|
|
||||||
"@jest/diff-sequences": ["@jest/diff-sequences@30.0.1", "", {}, "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw=="],
|
|
||||||
|
|
||||||
"@jest/environment": ["@jest/environment@29.7.0", "", { "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0" } }, "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw=="],
|
"@jest/environment": ["@jest/environment@29.7.0", "", { "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0" } }, "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw=="],
|
||||||
|
|
||||||
"@jest/expect-utils": ["@jest/expect-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA=="],
|
"@jest/expect-utils": ["@jest/expect-utils@29.7.0", "", { "dependencies": { "jest-get-type": "^29.6.3" } }, "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA=="],
|
||||||
|
|
||||||
"@jest/fake-timers": ["@jest/fake-timers@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", "jest-message-util": "^29.7.0", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ=="],
|
"@jest/fake-timers": ["@jest/fake-timers@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", "jest-message-util": "^29.7.0", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ=="],
|
||||||
|
|
||||||
"@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="],
|
"@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
||||||
|
|
||||||
"@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="],
|
|
||||||
|
|
||||||
"@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="],
|
|
||||||
|
|
||||||
"@jest/transform": ["@jest/transform@29.7.0", "", { "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", "@jridgewell/trace-mapping": "^0.3.18", "babel-plugin-istanbul": "^6.1.1", "chalk": "^4.0.0", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.9", "jest-haste-map": "^29.7.0", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "micromatch": "^4.0.4", "pirates": "^4.0.4", "slash": "^3.0.0", "write-file-atomic": "^4.0.2" } }, "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw=="],
|
"@jest/transform": ["@jest/transform@29.7.0", "", { "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", "@jridgewell/trace-mapping": "^0.3.18", "babel-plugin-istanbul": "^6.1.1", "chalk": "^4.0.0", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.9", "jest-haste-map": "^29.7.0", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "micromatch": "^4.0.4", "pirates": "^4.0.4", "slash": "^3.0.0", "write-file-atomic": "^4.0.2" } }, "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw=="],
|
||||||
|
|
||||||
@@ -577,7 +571,7 @@
|
|||||||
|
|
||||||
"@sideway/pinpoint": ["@sideway/pinpoint@2.0.0", "", {}, "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="],
|
"@sideway/pinpoint": ["@sideway/pinpoint@2.0.0", "", {}, "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="],
|
||||||
|
|
||||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
|
"@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
||||||
|
|
||||||
"@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="],
|
"@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="],
|
||||||
|
|
||||||
@@ -609,7 +603,7 @@
|
|||||||
|
|
||||||
"@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="],
|
"@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.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="],
|
"@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="],
|
||||||
|
|
||||||
@@ -903,6 +897,8 @@
|
|||||||
|
|
||||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||||
|
|
||||||
|
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
|
||||||
|
|
||||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||||
|
|
||||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
@@ -975,7 +971,7 @@
|
|||||||
|
|
||||||
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
|
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
|
||||||
|
|
||||||
"expect": ["expect@30.2.0", "", { "dependencies": { "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.2.0", "jest-message-util": "30.2.0", "jest-mock": "30.2.0", "jest-util": "30.2.0" } }, "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw=="],
|
"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@54.0.23", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.16", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devtools": "0.1.7", "@expo/fingerprint": "0.15.3", "@expo/metro": "~54.1.0", "@expo/metro-config": "54.0.9", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.7", "expo-asset": "~12.0.9", "expo-constants": "~18.0.10", "expo-file-system": "~19.0.17", "expo-font": "~14.0.9", "expo-keep-awake": "~15.0.7", "expo-modules-autolinking": "3.0.21", "expo-modules-core": "3.0.25", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-b4uQoiRwQ6nwqsT2709RS15CWYNGF3eJtyr1KyLw9WuMAK7u4jjofkhRiO0+3o1C2NbV+WooyYTOZGubQQMBaQ=="],
|
"expo": ["expo@54.0.23", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.16", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devtools": "0.1.7", "@expo/fingerprint": "0.15.3", "@expo/metro": "~54.1.0", "@expo/metro-config": "54.0.9", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.7", "expo-asset": "~12.0.9", "expo-constants": "~18.0.10", "expo-file-system": "~19.0.17", "expo-font": "~14.0.9", "expo-keep-awake": "~15.0.7", "expo-modules-autolinking": "3.0.21", "expo-modules-core": "3.0.25", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-b4uQoiRwQ6nwqsT2709RS15CWYNGF3eJtyr1KyLw9WuMAK7u4jjofkhRiO0+3o1C2NbV+WooyYTOZGubQQMBaQ=="],
|
||||||
|
|
||||||
@@ -1247,7 +1243,7 @@
|
|||||||
|
|
||||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||||
|
|
||||||
"jest-diff": ["jest-diff@30.2.0", "", { "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.2.0" } }, "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A=="],
|
"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-environment-node": ["jest-environment-node@29.7.0", "", { "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw=="],
|
"jest-environment-node": ["jest-environment-node@29.7.0", "", { "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw=="],
|
||||||
|
|
||||||
@@ -1255,15 +1251,15 @@
|
|||||||
|
|
||||||
"jest-haste-map": ["jest-haste-map@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/graceful-fs": "^4.1.3", "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", "graceful-fs": "^4.2.9", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.2" } }, "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA=="],
|
"jest-haste-map": ["jest-haste-map@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/graceful-fs": "^4.1.3", "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", "graceful-fs": "^4.2.9", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.2" } }, "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA=="],
|
||||||
|
|
||||||
"jest-matcher-utils": ["jest-matcher-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.2.0", "pretty-format": "30.2.0" } }, "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg=="],
|
"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.2.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.2.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "micromatch": "^4.0.8", "pretty-format": "30.2.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw=="],
|
"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.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "jest-util": "30.2.0" } }, "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw=="],
|
"jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="],
|
||||||
|
|
||||||
"jest-regex-util": ["jest-regex-util@29.6.3", "", {}, "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg=="],
|
"jest-regex-util": ["jest-regex-util@29.6.3", "", {}, "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg=="],
|
||||||
|
|
||||||
"jest-util": ["jest-util@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.2" } }, "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
@@ -1565,7 +1561,7 @@
|
|||||||
|
|
||||||
"pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="],
|
"pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="],
|
||||||
|
|
||||||
"pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="],
|
"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=="],
|
"proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="],
|
||||||
|
|
||||||
@@ -1609,7 +1605,7 @@
|
|||||||
|
|
||||||
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
||||||
|
|
||||||
"react-i18next": ["react-i18next@15.7.4", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.4.0", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw=="],
|
"react-i18next": ["react-i18next@16.0.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 25.5.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q=="],
|
||||||
|
|
||||||
"react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="],
|
"react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="],
|
||||||
|
|
||||||
@@ -2023,8 +2019,6 @@
|
|||||||
|
|
||||||
"@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
|
"@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
|
||||||
|
|
||||||
"@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.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
"@expo/cli/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
@@ -2079,8 +2073,6 @@
|
|||||||
|
|
||||||
"@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/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/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/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.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
"@expo/prebuild-config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||||
@@ -2099,22 +2091,8 @@
|
|||||||
|
|
||||||
"@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
|
"@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
|
||||||
|
|
||||||
"@jest/environment/jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="],
|
|
||||||
|
|
||||||
"@jest/fake-timers/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/fake-timers/jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="],
|
|
||||||
|
|
||||||
"@jest/fake-timers/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/pattern/jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="],
|
|
||||||
|
|
||||||
"@jest/transform/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/transform/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
"@jest/transform/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||||
|
|
||||||
"@jest/types/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
|
||||||
|
|
||||||
"@jimp/png/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
"@jimp/png/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
||||||
|
|
||||||
"@radix-ui/react-collection/@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-collection/@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=="],
|
||||||
@@ -2129,8 +2107,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/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-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"@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/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"@react-native/community-cli-plugin/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
@@ -2175,8 +2151,6 @@
|
|||||||
|
|
||||||
"error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
|
"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.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
"expo-build-properties/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||||
|
|
||||||
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
|
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
|
||||||
@@ -2209,28 +2183,8 @@
|
|||||||
|
|
||||||
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
|
|
||||||
"jest-environment-node/jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="],
|
|
||||||
|
|
||||||
"jest-environment-node/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-haste-map/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-message-util/@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@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-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="],
|
|
||||||
|
|
||||||
"jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
"jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||||
|
|
||||||
"jest-mock/@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@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-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="],
|
|
||||||
|
|
||||||
"jest-util/@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@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-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="],
|
|
||||||
|
|
||||||
"jest-util/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
|
||||||
|
|
||||||
"jest-util/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
|
||||||
|
|
||||||
"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-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=="],
|
"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=="],
|
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
@@ -2289,8 +2243,6 @@
|
|||||||
|
|
||||||
"react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
"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.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
|
|
||||||
"react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
@@ -2359,18 +2311,6 @@
|
|||||||
|
|
||||||
"@expo/cli/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
|
"@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/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"@expo/cli/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
|
||||||
|
|
||||||
"@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/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"@expo/metro-runtime/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
|
||||||
|
|
||||||
"@expo/metro/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
|
"@expo/metro/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
|
||||||
|
|
||||||
"@expo/metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
|
"@expo/metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
|
||||||
@@ -2393,22 +2333,8 @@
|
|||||||
|
|
||||||
"@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
"@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||||
|
|
||||||
"@jest/environment/jest-mock/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/fake-timers/jest-message-util/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/fake-timers/jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
|
||||||
|
|
||||||
"@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
|
"@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/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"@react-native-community/cli-server-api/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
|
||||||
|
|
||||||
"@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-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=="],
|
"@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=="],
|
||||||
@@ -2441,22 +2367,10 @@
|
|||||||
|
|
||||||
"connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"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/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"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=="],
|
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|
||||||
"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/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"jest-validate/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
|
||||||
|
|
||||||
"lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"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=="],
|
"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=="],
|
||||||
@@ -2487,12 +2401,6 @@
|
|||||||
|
|
||||||
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|
||||||
"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/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
@@ -2525,10 +2433,6 @@
|
|||||||
|
|
||||||
"@expo/cli/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
"@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.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"@expo/metro-runtime/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"@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/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=="],
|
"@expo/package-manager/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||||
@@ -2541,14 +2445,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=="],
|
"@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=="],
|
||||||
|
|
||||||
"@jest/fake-timers/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
|
||||||
|
|
||||||
"@jest/fake-timers/jest-message-util/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"@jest/fake-timers/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
|
||||||
|
|
||||||
"@react-native-community/cli-server-api/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"@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-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=="],
|
"@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
@@ -2569,10 +2465,6 @@
|
|||||||
|
|
||||||
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
"expo/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"jest-validate/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"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/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=="],
|
"log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||||
@@ -2583,8 +2475,6 @@
|
|||||||
|
|
||||||
"logkitty/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
"logkitty/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||||
|
|
||||||
"react-native/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
"@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||||
@@ -2603,8 +2493,6 @@
|
|||||||
|
|
||||||
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||||
|
|
||||||
"@jest/fake-timers/jest-message-util/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"ansi-fragments/slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
"ansi-fragments/slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { type Bitrate } from "@/components/BitrateSelector";
|
import { type Bitrate } from "@/components/BitrateSelector";
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
|
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||||
@@ -23,19 +24,16 @@ import { CurrentSeries } from "@/components/series/CurrentSeries";
|
|||||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
import { BitrateSheet } from "./BitRateSheet";
|
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
|
||||||
import { MediaSourceSheet } from "./MediaSourceSheet";
|
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||||
import { TrackSheet } from "./TrackSheet";
|
|
||||||
|
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
@@ -70,6 +68,9 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
SelectedOptions | undefined
|
SelectedOptions | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
|
// preload media sources
|
||||||
|
useItemQuery(item.Id, false, undefined, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
@@ -201,76 +202,27 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col bg-transparent shrink'>
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
||||||
<ItemHeader item={item} className='mb-2' />
|
<ItemHeader item={item} className='mb-2' />
|
||||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
|
||||||
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
|
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
||||||
<BitrateSheet
|
<PlayButton
|
||||||
className='mr-1'
|
selectedOptions={selectedOptions}
|
||||||
onChange={(val) =>
|
item={item}
|
||||||
setSelectedOptions(
|
isOffline={isOffline}
|
||||||
(prev) => prev && { ...prev, bitrate: val },
|
colors={itemColors}
|
||||||
)
|
/>
|
||||||
}
|
<View className='w-1' />
|
||||||
selected={selectedOptions.bitrate}
|
{!isOffline && (
|
||||||
/>
|
<MediaSourceButton
|
||||||
<MediaSourceSheet
|
selectedOptions={selectedOptions}
|
||||||
className='mr-1'
|
setSelectedOptions={setSelectedOptions}
|
||||||
item={item}
|
item={item}
|
||||||
onChange={(val) =>
|
colors={itemColors}
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
mediaSource: val,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.mediaSource}
|
|
||||||
/>
|
/>
|
||||||
<TrackSheet
|
)}
|
||||||
className='mr-1'
|
</View>
|
||||||
streamType='Audio'
|
|
||||||
title={t("item_card.audio")}
|
|
||||||
source={selectedOptions.mediaSource}
|
|
||||||
onChange={(val) => {
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
audioIndex: val,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
selected={selectedOptions.audioIndex}
|
|
||||||
/>
|
|
||||||
<TrackSheet
|
|
||||||
source={selectedOptions.mediaSource}
|
|
||||||
streamType='Subtitle'
|
|
||||||
title={t("item_card.subtitles")}
|
|
||||||
onChange={(val) =>
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
subtitleIndex: val,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.subtitleIndex}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlayButton
|
|
||||||
className='grow'
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
item={item}
|
|
||||||
isOffline={isOffline}
|
|
||||||
colors={itemColors}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<SeasonEpisodesCarousel
|
<SeasonEpisodesCarousel
|
||||||
item={item}
|
item={item}
|
||||||
@@ -279,9 +231,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOffline && (
|
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
|
||||||
)}
|
|
||||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
|
|||||||
203
components/MediaSourceButton.tsx
Normal file
203
components/MediaSourceButton.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||||
|
import { BITRATES } from "./BitRateSheet";
|
||||||
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
|
item: BaseItemDto;
|
||||||
|
selectedOptions: SelectedOptions;
|
||||||
|
setSelectedOptions: React.Dispatch<
|
||||||
|
React.SetStateAction<SelectedOptions | undefined>
|
||||||
|
>;
|
||||||
|
colors?: ThemeColors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaSourceButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
setSelectedOptions,
|
||||||
|
colors,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { data: itemWithSources, isLoading } = useItemQuery(
|
||||||
|
item.Id,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const effectiveColors = colors || {
|
||||||
|
primary: "#7c3aed",
|
||||||
|
text: "#000000",
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const firstMediaSource = itemWithSources?.MediaSources?.[0];
|
||||||
|
if (!firstMediaSource) return;
|
||||||
|
setSelectedOptions((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
mediaSource: firstMediaSource,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [itemWithSources, setSelectedOptions]);
|
||||||
|
|
||||||
|
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
|
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||||
|
if (source.Name) return source.Name;
|
||||||
|
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
|
||||||
|
return `Source ${source.Id}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const audioStreams = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
|
(x) => x.Type === "Audio",
|
||||||
|
) || [],
|
||||||
|
[selectedOptions.mediaSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtitleStreams = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
|
(x) => x.Type === "Subtitle",
|
||||||
|
) || [],
|
||||||
|
[selectedOptions.mediaSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||||
|
const groups: OptionGroup[] = [];
|
||||||
|
|
||||||
|
// Bitrate group
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.quality"),
|
||||||
|
options: BITRATES.map((bitrate) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: bitrate.key,
|
||||||
|
value: bitrate,
|
||||||
|
selected: bitrate.value === selectedOptions.bitrate?.value,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Media Source group (only if multiple sources)
|
||||||
|
if (
|
||||||
|
itemWithSources?.MediaSources &&
|
||||||
|
itemWithSources.MediaSources.length > 1
|
||||||
|
) {
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.video"),
|
||||||
|
options: itemWithSources.MediaSources.map((source) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: getMediaSourceDisplayName(source),
|
||||||
|
value: source,
|
||||||
|
selected: source.Id === selectedOptions.mediaSource?.Id,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, mediaSource: source },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio track group
|
||||||
|
if (audioStreams.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.audio"),
|
||||||
|
options: audioStreams.map((stream) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
||||||
|
value: stream.Index,
|
||||||
|
selected: stream.Index === selectedOptions.audioIndex,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, audioIndex: stream.Index ?? 0 },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle track group (with None option)
|
||||||
|
if (subtitleStreams.length > 0) {
|
||||||
|
const noneOption = {
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("common.none"),
|
||||||
|
value: -1,
|
||||||
|
selected: selectedOptions.subtitleIndex === -1,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions((prev) => prev && { ...prev, subtitleIndex: -1 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitleOptions = subtitleStreams.map((stream) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
||||||
|
value: stream.Index,
|
||||||
|
selected: stream.Index === selectedOptions.subtitleIndex,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, subtitleIndex: stream.Index ?? -1 },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.subtitles"),
|
||||||
|
options: [noneOption, ...subtitleOptions],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [
|
||||||
|
itemWithSources,
|
||||||
|
selectedOptions,
|
||||||
|
audioStreams,
|
||||||
|
subtitleStreams,
|
||||||
|
getMediaSourceDisplayName,
|
||||||
|
t,
|
||||||
|
setSelectedOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<TouchableOpacity
|
||||||
|
disabled={!item || isLoading}
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
className='relative'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{ backgroundColor: effectiveColors.primary, opacity: 0.7 }}
|
||||||
|
className='absolute w-12 h-12 rounded-full'
|
||||||
|
/>
|
||||||
|
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size='small' color={effectiveColors.text} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='list' size={24} color={effectiveColors.text} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={optionGroups}
|
||||||
|
trigger={trigger}
|
||||||
|
title={t("item_card.media_options")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
bottomSheetConfig={{
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -184,7 +184,7 @@ const PlatformDropdownComponent = ({
|
|||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -207,6 +207,14 @@ const PlatformDropdownComponent = ({
|
|||||||
}
|
}
|
||||||
}, [controlledOpen]);
|
}, [controlledOpen]);
|
||||||
|
|
||||||
|
// Watch for modal dismissal on Android (e.g., swipe down, backdrop tap)
|
||||||
|
// and sync the controlled open state
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
|
||||||
|
controlledOnOpenChange?.(false);
|
||||||
|
}
|
||||||
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<Host style={expoUIConfig?.hostStyle}>
|
<Host style={expoUIConfig?.hostStyle}>
|
||||||
|
|||||||
@@ -358,9 +358,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
[startColor.value.text, endColor.value.text],
|
[startColor.value.text, endColor.value.text],
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
/**
|
|
||||||
* *********************
|
|
||||||
*/
|
|
||||||
|
|
||||||
// if (Platform.OS === "ios")
|
// if (Platform.OS === "ios")
|
||||||
// return (
|
// return (
|
||||||
@@ -414,7 +411,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
accessibilityLabel='Play button'
|
accessibilityLabel='Play button'
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative flex-1"}
|
||||||
>
|
>
|
||||||
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
|
|||||||
212
components/settings/StorageLocationPicker.tsx
Normal file
212
components/settings/StorageLocationPicker.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetScrollView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { forwardRef, useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import type { StorageLocation } from "@/modules";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import {
|
||||||
|
clearStorageLocationsCache,
|
||||||
|
getAvailableStorageLocations,
|
||||||
|
} from "@/utils/storage";
|
||||||
|
|
||||||
|
interface StorageLocationPickerProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StorageLocationPicker = forwardRef<
|
||||||
|
BottomSheetModal,
|
||||||
|
StorageLocationPickerProps
|
||||||
|
>(({ onClose }, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { settings, updateSettings } = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [selectedId, setSelectedId] = useState<string | undefined>(
|
||||||
|
settings.downloadStorageLocation || "internal",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: locations, isLoading } = useQuery({
|
||||||
|
queryKey: ["storageLocations"],
|
||||||
|
queryFn: getAvailableStorageLocations,
|
||||||
|
enabled: Platform.OS === "android",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSelect = (location: StorageLocation) => {
|
||||||
|
setSelectedId(location.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
updateSettings({ downloadStorageLocation: selectedId });
|
||||||
|
clearStorageLocationsCache(); // Clear cache so next download uses new location
|
||||||
|
toast.success(
|
||||||
|
t("settings.storage.storage_location_updated", {
|
||||||
|
defaultValue: "Storage location updated",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Platform.OS !== "android") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={ref}
|
||||||
|
enableDynamicSizing
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
enablePanDownToClose
|
||||||
|
enableDismissOnClose
|
||||||
|
>
|
||||||
|
<BottomSheetScrollView
|
||||||
|
style={{
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='px-4 pt-2'>
|
||||||
|
<Text className='text-lg font-semibold mb-1'>
|
||||||
|
{t("settings.storage.select_storage_location", {
|
||||||
|
defaultValue: "Select Storage Location",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-sm text-neutral-500 mb-4'>
|
||||||
|
{t("settings.storage.existing_downloads_note", {
|
||||||
|
defaultValue:
|
||||||
|
"Existing downloads will remain in their current location",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View className='items-center justify-center py-8'>
|
||||||
|
<ActivityIndicator size='large' />
|
||||||
|
<Text className='mt-4 text-neutral-500'>
|
||||||
|
{t("settings.storage.loading_storage", {
|
||||||
|
defaultValue: "Loading storage options...",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : !locations || locations.length === 0 ? (
|
||||||
|
<View className='items-center justify-center py-8'>
|
||||||
|
<Text className='text-neutral-500'>
|
||||||
|
{t("settings.storage.no_storage_found", {
|
||||||
|
defaultValue: "No storage locations found",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{locations.map((location) => {
|
||||||
|
const isSelected = selectedId === location.id;
|
||||||
|
const freeSpaceGB = (location.freeSpace / 1024 ** 3).toFixed(2);
|
||||||
|
const totalSpaceGB = (location.totalSpace / 1024 ** 3).toFixed(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const usedPercent = (
|
||||||
|
((location.totalSpace - location.freeSpace) /
|
||||||
|
location.totalSpace) *
|
||||||
|
100
|
||||||
|
).toFixed(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={location.id}
|
||||||
|
onPress={() => handleSelect(location)}
|
||||||
|
className={`p-4 mb-2 rounded-lg ${
|
||||||
|
isSelected
|
||||||
|
? "bg-purple-600/20 border border-purple-600"
|
||||||
|
: "bg-neutral-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center justify-between'>
|
||||||
|
<View className='flex-1'>
|
||||||
|
<View className='flex-row items-center'>
|
||||||
|
<Text className='text-base font-semibold'>
|
||||||
|
{location.label}
|
||||||
|
</Text>
|
||||||
|
{location.type === "external" && (
|
||||||
|
<View className='ml-2 px-2 py-0.5 bg-blue-600/30 rounded'>
|
||||||
|
<Text className='text-xs text-blue-400'>
|
||||||
|
{t("settings.storage.removable", {
|
||||||
|
defaultValue: "Removable",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text className='text-sm text-neutral-500 mt-1'>
|
||||||
|
{t("settings.storage.space_info", {
|
||||||
|
defaultValue:
|
||||||
|
"{{free}} GB free of {{total}} GB ({{used}}% used)",
|
||||||
|
free: freeSpaceGB,
|
||||||
|
total: totalSpaceGB,
|
||||||
|
used: usedPercent,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isSelected && (
|
||||||
|
<View className='w-6 h-6 rounded-full bg-purple-600 items-center justify-center ml-2'>
|
||||||
|
<Text className='text-white text-xs'>✓</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<View className='flex-row gap-x-2 py-4'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onClose}
|
||||||
|
className='flex-1 py-3 rounded-lg bg-neutral-800 items-center'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>
|
||||||
|
{t("common.cancel", { defaultValue: "Cancel" })}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleConfirm}
|
||||||
|
className='flex-1 py-3 rounded-lg bg-purple-600 items-center'
|
||||||
|
disabled={!selectedId}
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>
|
||||||
|
{t("common.confirm", { defaultValue: "Confirm" })}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
@@ -6,14 +8,19 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getStorageLabel } from "@/utils/storage";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { StorageLocationPicker } from "./StorageLocationPicker";
|
||||||
|
|
||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const { data: size } = useQuery({
|
const { data: size } = useQuery({
|
||||||
queryKey: ["appSize"],
|
queryKey: ["appSize"],
|
||||||
@@ -29,6 +36,12 @@ export const StorageSettings = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: storageLabel } = useQuery({
|
||||||
|
queryKey: ["storageLabel", settings.downloadStorageLocation],
|
||||||
|
queryFn: () => getStorageLabel(settings.downloadStorageLocation),
|
||||||
|
enabled: Platform.OS === "android",
|
||||||
|
});
|
||||||
|
|
||||||
const onDeleteClicked = async () => {
|
const onDeleteClicked = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAllFiles();
|
await deleteAllFiles();
|
||||||
@@ -102,14 +115,32 @@ export const StorageSettings = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<ListGroup>
|
<>
|
||||||
<ListItem
|
{Platform.OS === "android" && (
|
||||||
textColor='red'
|
<ListGroup>
|
||||||
onPress={onDeleteClicked}
|
<ListItem
|
||||||
title={t("home.settings.storage.delete_all_downloaded_files")}
|
title={t("settings.storage.download_location", {
|
||||||
/>
|
defaultValue: "Download Location",
|
||||||
</ListGroup>
|
})}
|
||||||
|
value={storageLabel || "Internal Storage"}
|
||||||
|
onPress={() => bottomSheetModalRef.current?.present()}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
)}
|
||||||
|
<ListGroup>
|
||||||
|
<ListItem
|
||||||
|
textColor='red'
|
||||||
|
onPress={onDeleteClicked}
|
||||||
|
title={t("home.settings.storage.delete_all_downloaded_files")}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<StorageLocationPicker
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
onClose={() => bottomSheetModalRef.current?.dismiss()}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ const SkipButton: React.FC<SkipButtonProps> = ({
|
|||||||
buttonText,
|
buttonText,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
console.log(`[SKIP_BUTTON] Render:`, {
|
|
||||||
buttonText,
|
|
||||||
showButton,
|
|
||||||
className: showButton ? "flex" : "hidden",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className={showButton ? "flex" : "hidden"} {...props}>
|
<View className={showButton ? "flex" : "hidden"} {...props}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -130,7 +130,13 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTracks = async () => {
|
const fetchTracks = async () => {
|
||||||
if (getSubtitleTracks) {
|
if (getSubtitleTracks) {
|
||||||
let subtitleData = await getSubtitleTracks();
|
let subtitleData: TrackInfo[] | null = null;
|
||||||
|
try {
|
||||||
|
subtitleData = await getSubtitleTracks();
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[VideoContext] Failed to get subtitle tracks:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles.
|
// Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles.
|
||||||
if (
|
if (
|
||||||
mediaSource?.TranscodingUrl &&
|
mediaSource?.TranscodingUrl &&
|
||||||
@@ -179,7 +185,13 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
setSubtitleTracks(subtitles);
|
setSubtitleTracks(subtitles);
|
||||||
}
|
}
|
||||||
if (getAudioTracks) {
|
if (getAudioTracks) {
|
||||||
const audioData = await getAudioTracks();
|
let audioData: TrackInfo[] | null = null;
|
||||||
|
try {
|
||||||
|
audioData = await getAudioTracks();
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[VideoContext] Failed to get audio tracks:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const allAudio =
|
const allAudio =
|
||||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||||
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const useGestureDetection = ({
|
|||||||
onVerticalDragEnd,
|
onVerticalDragEnd,
|
||||||
onTap,
|
onTap,
|
||||||
screenWidth = 400,
|
screenWidth = 400,
|
||||||
|
screenHeight = 800,
|
||||||
}: SwipeGestureOptions = {}) => {
|
}: SwipeGestureOptions = {}) => {
|
||||||
const touchStartTime = useRef(0);
|
const touchStartTime = useRef(0);
|
||||||
const touchStartPosition = useRef({ x: 0, y: 0 });
|
const touchStartPosition = useRef({ x: 0, y: 0 });
|
||||||
@@ -36,25 +37,47 @@ export const useGestureDetection = ({
|
|||||||
const dragSide = useRef<"left" | "right" | null>(null);
|
const dragSide = useRef<"left" | "right" | null>(null);
|
||||||
const hasMovedEnough = useRef(false);
|
const hasMovedEnough = useRef(false);
|
||||||
const gestureType = useRef<"none" | "horizontal" | "vertical">("none");
|
const gestureType = useRef<"none" | "horizontal" | "vertical">("none");
|
||||||
|
const shouldIgnoreTouch = useRef(false);
|
||||||
|
|
||||||
const handleTouchStart = useCallback((event: GestureResponderEvent) => {
|
const handleTouchStart = useCallback(
|
||||||
touchStartTime.current = Date.now();
|
(event: GestureResponderEvent) => {
|
||||||
touchStartPosition.current = {
|
const startY = event.nativeEvent.pageY;
|
||||||
x: event.nativeEvent.pageX,
|
|
||||||
y: event.nativeEvent.pageY,
|
// Define exclusion zones (15% from top and bottom)
|
||||||
};
|
const topExclusionZone = screenHeight * 0.15;
|
||||||
lastTouchPosition.current = {
|
const bottomExclusionZone = screenHeight * 0.85;
|
||||||
x: event.nativeEvent.pageX,
|
|
||||||
y: event.nativeEvent.pageY,
|
// Check if touch started in exclusion zones
|
||||||
};
|
if (startY < topExclusionZone || startY > bottomExclusionZone) {
|
||||||
isDragging.current = false;
|
shouldIgnoreTouch.current = true;
|
||||||
dragSide.current = null;
|
return;
|
||||||
hasMovedEnough.current = false;
|
}
|
||||||
gestureType.current = "none";
|
|
||||||
}, []);
|
shouldIgnoreTouch.current = false;
|
||||||
|
touchStartTime.current = Date.now();
|
||||||
|
touchStartPosition.current = {
|
||||||
|
x: event.nativeEvent.pageX,
|
||||||
|
y: startY,
|
||||||
|
};
|
||||||
|
lastTouchPosition.current = {
|
||||||
|
x: event.nativeEvent.pageX,
|
||||||
|
y: startY,
|
||||||
|
};
|
||||||
|
isDragging.current = false;
|
||||||
|
dragSide.current = null;
|
||||||
|
hasMovedEnough.current = false;
|
||||||
|
gestureType.current = "none";
|
||||||
|
},
|
||||||
|
[screenHeight],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTouchMove = useCallback(
|
const handleTouchMove = useCallback(
|
||||||
(event: GestureResponderEvent) => {
|
(event: GestureResponderEvent) => {
|
||||||
|
// Ignore touch if it started in exclusion zone
|
||||||
|
if (shouldIgnoreTouch.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentPosition = {
|
const currentPosition = {
|
||||||
x: event.nativeEvent.pageX,
|
x: event.nativeEvent.pageX,
|
||||||
y: event.nativeEvent.pageY,
|
y: event.nativeEvent.pageY,
|
||||||
@@ -106,6 +129,12 @@ export const useGestureDetection = ({
|
|||||||
|
|
||||||
const handleTouchEnd = useCallback(
|
const handleTouchEnd = useCallback(
|
||||||
(event: GestureResponderEvent) => {
|
(event: GestureResponderEvent) => {
|
||||||
|
// Ignore touch if it started in exclusion zone
|
||||||
|
if (shouldIgnoreTouch.current) {
|
||||||
|
shouldIgnoreTouch.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const touchEndTime = Date.now();
|
const touchEndTime = Date.now();
|
||||||
const touchEndPosition = {
|
const touchEndPosition = {
|
||||||
x: event.nativeEvent.pageX,
|
x: event.nativeEvent.pageX,
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTracks = async () => {
|
const fetchTracks = async () => {
|
||||||
if (playerRef.current) {
|
if (playerRef.current) {
|
||||||
const audio = await playerRef.current.getAudioTracks();
|
try {
|
||||||
const subtitles = await playerRef.current.getSubtitleTracks();
|
const audio = await playerRef.current.getAudioTracks();
|
||||||
setAudioTracks(audio);
|
const subtitles = await playerRef.current.getSubtitleTracks();
|
||||||
setSubtitleTracks(subtitles);
|
setAudioTracks(audio);
|
||||||
|
setSubtitleTracks(subtitles);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[VideoDebugInfo] Failed to fetch tracks:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,8 +64,24 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
|
|||||||
className='mt-2.5 bg-blue-500 p-2 rounded'
|
className='mt-2.5 bg-blue-500 p-2 rounded'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (playerRef.current) {
|
if (playerRef.current) {
|
||||||
playerRef.current.getAudioTracks().then(setAudioTracks);
|
playerRef.current
|
||||||
playerRef.current.getSubtitleTracks().then(setSubtitleTracks);
|
.getAudioTracks()
|
||||||
|
.then(setAudioTracks)
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(
|
||||||
|
"[VideoDebugInfo] Failed to get audio tracks:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
playerRef.current
|
||||||
|
.getSubtitleTracks()
|
||||||
|
.then(setSubtitleTracks)
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(
|
||||||
|
"[VideoDebugInfo] Failed to get subtitle tracks:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
6
eas.json
6
eas.json
@@ -45,14 +45,14 @@
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.47.1",
|
"channel": "0.48.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.47.1",
|
"channel": "0.48.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.47.1",
|
"channel": "0.48.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -43,37 +43,14 @@ export const useIntroSkipper = (
|
|||||||
const introTimestamps = segments?.introSegments?.[0];
|
const introTimestamps = segments?.introSegments?.[0];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(`[INTRO_SKIPPER] Hook state:`, {
|
|
||||||
itemId,
|
|
||||||
currentTime,
|
|
||||||
hasSegments: !!segments,
|
|
||||||
segments: segments,
|
|
||||||
introSegmentsCount: segments?.introSegments?.length || 0,
|
|
||||||
introSegments: segments?.introSegments,
|
|
||||||
hasIntroTimestamps: !!introTimestamps,
|
|
||||||
introTimestamps,
|
|
||||||
isVlc,
|
|
||||||
isOffline,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (introTimestamps) {
|
if (introTimestamps) {
|
||||||
const shouldShow =
|
const shouldShow =
|
||||||
currentTime > introTimestamps.startTime &&
|
currentTime > introTimestamps.startTime &&
|
||||||
currentTime < introTimestamps.endTime;
|
currentTime < introTimestamps.endTime;
|
||||||
|
|
||||||
console.log(`[INTRO_SKIPPER] Button visibility check:`, {
|
|
||||||
currentTime,
|
|
||||||
introStart: introTimestamps.startTime,
|
|
||||||
introEnd: introTimestamps.endTime,
|
|
||||||
afterStart: currentTime > introTimestamps.startTime,
|
|
||||||
beforeEnd: currentTime < introTimestamps.endTime,
|
|
||||||
shouldShow,
|
|
||||||
});
|
|
||||||
|
|
||||||
setShowSkipButton(shouldShow);
|
setShowSkipButton(shouldShow);
|
||||||
} else {
|
} else {
|
||||||
if (showSkipButton) {
|
if (showSkipButton) {
|
||||||
console.log(`[INTRO_SKIPPER] No intro timestamps, hiding button`);
|
|
||||||
setShowSkipButton(false);
|
setShowSkipButton(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,10 +59,6 @@ export const useIntroSkipper = (
|
|||||||
const skipIntro = useCallback(() => {
|
const skipIntro = useCallback(() => {
|
||||||
if (!introTimestamps) return;
|
if (!introTimestamps) return;
|
||||||
try {
|
try {
|
||||||
console.log(
|
|
||||||
`[INTRO_SKIPPER] Skipping intro to:`,
|
|
||||||
introTimestamps.endTime,
|
|
||||||
);
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
wrappedSeek(introTimestamps.endTime);
|
wrappedSeek(introTimestamps.endTime);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -96,7 +69,5 @@ export const useIntroSkipper = (
|
|||||||
}
|
}
|
||||||
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
||||||
|
|
||||||
console.log(`[INTRO_SKIPPER] Returning state:`, { showSkipButton });
|
|
||||||
|
|
||||||
return { showSkipButton, skipIntro };
|
return { showSkipButton, skipIntro };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +1,54 @@
|
|||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export const useItemQuery = (itemId: string, isOffline: boolean) => {
|
// Helper to exclude specific fields
|
||||||
|
export const excludeFields = (fieldsToExclude: ItemFields[]) => {
|
||||||
|
return Object.values(ItemFields).filter(
|
||||||
|
(field) => !fieldsToExclude.includes(field),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useItemQuery = (
|
||||||
|
itemId: string | undefined,
|
||||||
|
isOffline?: boolean,
|
||||||
|
fields?: ItemFields[],
|
||||||
|
excludeFields?: ItemFields[],
|
||||||
|
) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { getDownloadedItemById } = useDownload();
|
const { getDownloadedItemById } = useDownload();
|
||||||
|
|
||||||
|
// Calculate final fields: use excludeFields if provided, otherwise use fields
|
||||||
|
const finalFields = excludeFields
|
||||||
|
? Object.values(ItemFields).filter(
|
||||||
|
(field) => !excludeFields.includes(field),
|
||||||
|
)
|
||||||
|
: fields;
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["item", itemId],
|
queryKey: ["item", itemId, finalFields],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
if (!itemId) throw new Error("Item ID is required");
|
||||||
|
|
||||||
if (isOffline) {
|
if (isOffline) {
|
||||||
return getDownloadedItemById(itemId)?.item;
|
return getDownloadedItemById(itemId)?.item;
|
||||||
}
|
}
|
||||||
if (!api || !user || !itemId) return null;
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
if (!api || !user) return null;
|
||||||
itemId: itemId,
|
|
||||||
userId: user?.Id,
|
const response = await getItemsApi(api).getItems({
|
||||||
|
ids: [itemId],
|
||||||
|
userId: user.Id,
|
||||||
|
...(finalFields && { fields: finalFields }),
|
||||||
});
|
});
|
||||||
return res.data;
|
|
||||||
|
return response.data.Items?.[0];
|
||||||
},
|
},
|
||||||
staleTime: 0,
|
enabled: !!itemId,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ export const useJellyseerr = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const jellyseerrRegion = useMemo(
|
const jellyseerrRegion = useMemo(
|
||||||
() => jellyseerrUser?.settings?.discoverRegion || "US",
|
() => jellyseerrUser?.settings?.region || "US",
|
||||||
[jellyseerrUser],
|
[jellyseerrUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ export type ChapterInfo = {
|
|||||||
duration: number;
|
duration: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NowPlayingMetadata = {
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
albumTitle?: string;
|
||||||
|
artworkUri?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type VlcPlayerViewProps = {
|
export type VlcPlayerViewProps = {
|
||||||
source: VlcPlayerSource;
|
source: VlcPlayerSource;
|
||||||
style?: ViewStyle | ViewStyle[];
|
style?: ViewStyle | ViewStyle[];
|
||||||
@@ -67,6 +74,7 @@ export type VlcPlayerViewProps = {
|
|||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
volume?: number;
|
volume?: number;
|
||||||
videoAspectRatio?: string;
|
videoAspectRatio?: string;
|
||||||
|
nowPlayingMetadata?: NowPlayingMetadata;
|
||||||
onVideoProgress?: (event: ProgressUpdatePayload) => void;
|
onVideoProgress?: (event: ProgressUpdatePayload) => void;
|
||||||
onVideoStateChange?: (event: PlaybackStatePayload) => void;
|
onVideoStateChange?: (event: PlaybackStatePayload) => void;
|
||||||
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|||||||
muted,
|
muted,
|
||||||
volume,
|
volume,
|
||||||
videoAspectRatio,
|
videoAspectRatio,
|
||||||
|
nowPlayingMetadata,
|
||||||
onVideoLoadStart,
|
onVideoLoadStart,
|
||||||
onVideoStateChange,
|
onVideoStateChange,
|
||||||
onVideoProgress,
|
onVideoProgress,
|
||||||
@@ -131,6 +132,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|||||||
muted={muted}
|
muted={muted}
|
||||||
volume={volume}
|
volume={volume}
|
||||||
videoAspectRatio={videoAspectRatio}
|
videoAspectRatio={videoAspectRatio}
|
||||||
|
nowPlayingMetadata={nowPlayingMetadata}
|
||||||
onVideoLoadStart={onVideoLoadStart}
|
onVideoLoadStart={onVideoLoadStart}
|
||||||
onVideoLoadEnd={onVideoLoadEnd}
|
onVideoLoadEnd={onVideoLoadEnd}
|
||||||
onVideoStateChange={onVideoStateChange}
|
onVideoStateChange={onVideoStateChange}
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ import android.content.ComponentName
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.storage.StorageManager
|
||||||
|
import android.os.storage.StorageVolume
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import expo.modules.kotlin.Promise
|
import expo.modules.kotlin.Promise
|
||||||
import expo.modules.kotlin.modules.Module
|
import expo.modules.kotlin.modules.Module
|
||||||
import expo.modules.kotlin.modules.ModuleDefinition
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
data class DownloadTaskInfo(
|
data class DownloadTaskInfo(
|
||||||
val url: String,
|
val url: String,
|
||||||
@@ -142,6 +147,105 @@ class BackgroundDownloaderModule : Module() {
|
|||||||
promise.reject("ERROR", "Failed to get active downloads: ${e.message}", e)
|
promise.reject("ERROR", "Failed to get active downloads: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getAvailableStorageLocations") { promise: Promise ->
|
||||||
|
try {
|
||||||
|
val storageLocations = mutableListOf<Map<String, Any>>()
|
||||||
|
|
||||||
|
// Use getExternalFilesDirs which works reliably across all Android versions
|
||||||
|
// This returns app-specific directories on both internal and external storage
|
||||||
|
val externalDirs = context.getExternalFilesDirs(null)
|
||||||
|
|
||||||
|
Log.d(TAG, "getExternalFilesDirs returned ${externalDirs.size} locations")
|
||||||
|
|
||||||
|
// Also check with StorageManager for additional info
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||||
|
val volumes = storageManager.storageVolumes
|
||||||
|
Log.d(TAG, "StorageManager reports ${volumes.size} volumes")
|
||||||
|
for ((i, vol) in volumes.withIndex()) {
|
||||||
|
Log.d(TAG, " Volume $i: isPrimary=${vol.isPrimary}, isRemovable=${vol.isRemovable}, state=${vol.state}, uuid=${vol.uuid}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((index, dir) in externalDirs.withIndex()) {
|
||||||
|
try {
|
||||||
|
if (dir == null) {
|
||||||
|
Log.w(TAG, "Directory at index $index is null - SD card may not be mounted")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dir.exists()) {
|
||||||
|
Log.w(TAG, "Directory at index $index does not exist: ${dir.absolutePath}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val isPrimary = index == 0
|
||||||
|
val isRemovable = !isPrimary && Environment.isExternalStorageRemovable(dir)
|
||||||
|
|
||||||
|
// Get volume UUID for better identification
|
||||||
|
val volumeId = if (isPrimary) {
|
||||||
|
"internal"
|
||||||
|
} else {
|
||||||
|
// Try to get a stable UUID for the SD card
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||||
|
try {
|
||||||
|
val storageVolume = storageManager.getStorageVolume(dir)
|
||||||
|
storageVolume?.uuid ?: "sdcard_$index"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"sdcard_$index"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"sdcard_$index"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get human-readable label
|
||||||
|
val label = if (isPrimary) {
|
||||||
|
"Internal Storage"
|
||||||
|
} else {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||||
|
try {
|
||||||
|
val storageVolume = storageManager.getStorageVolume(dir)
|
||||||
|
storageVolume?.getDescription(context) ?: "SD Card"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"SD Card"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"SD Card"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val totalSpace = dir.totalSpace
|
||||||
|
val freeSpace = dir.freeSpace
|
||||||
|
|
||||||
|
Log.d(TAG, "Storage location: $label (id: $volumeId, path: ${dir.absolutePath}, removable: $isRemovable)")
|
||||||
|
|
||||||
|
storageLocations.add(
|
||||||
|
mapOf(
|
||||||
|
"id" to volumeId,
|
||||||
|
"path" to dir.absolutePath,
|
||||||
|
"type" to (if (isRemovable || !isPrimary) "external" else "internal"),
|
||||||
|
"label" to label,
|
||||||
|
"totalSpace" to totalSpace,
|
||||||
|
"freeSpace" to freeSpace
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error processing storage at index $index: ${e.message}", e)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Returning ${storageLocations.size} storage locations")
|
||||||
|
promise.resolve(storageLocations)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error getting storage locations: ${e.message}", e)
|
||||||
|
promise.reject("ERROR", "Failed to get storage locations: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startDownloadInternal(urlString: String, destinationPath: String?): Int {
|
private fun startDownloadInternal(urlString: String, destinationPath: String?): Int {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
DownloadErrorEvent,
|
DownloadErrorEvent,
|
||||||
DownloadProgressEvent,
|
DownloadProgressEvent,
|
||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
|
StorageLocation,
|
||||||
} from "./src/BackgroundDownloader.types";
|
} from "./src/BackgroundDownloader.types";
|
||||||
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
|
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export interface BackgroundDownloader {
|
|||||||
cancelQueuedDownload(url: string): void;
|
cancelQueuedDownload(url: string): void;
|
||||||
cancelAllDownloads(): void;
|
cancelAllDownloads(): void;
|
||||||
getActiveDownloads(): Promise<ActiveDownload[]>;
|
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||||
|
getAvailableStorageLocations(): Promise<StorageLocation[]>;
|
||||||
|
|
||||||
addProgressListener(
|
addProgressListener(
|
||||||
listener: (event: DownloadProgressEvent) => void,
|
listener: (event: DownloadProgressEvent) => void,
|
||||||
@@ -64,6 +66,10 @@ const BackgroundDownloader: BackgroundDownloader = {
|
|||||||
return await BackgroundDownloaderModule.getActiveDownloads();
|
return await BackgroundDownloaderModule.getActiveDownloads();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getAvailableStorageLocations(): Promise<StorageLocation[]> {
|
||||||
|
return await BackgroundDownloaderModule.getAvailableStorageLocations();
|
||||||
|
},
|
||||||
|
|
||||||
addProgressListener(
|
addProgressListener(
|
||||||
listener: (event: DownloadProgressEvent) => void,
|
listener: (event: DownloadProgressEvent) => void,
|
||||||
): EventSubscription {
|
): EventSubscription {
|
||||||
@@ -106,4 +112,5 @@ export type {
|
|||||||
DownloadErrorEvent,
|
DownloadErrorEvent,
|
||||||
DownloadProgressEvent,
|
DownloadProgressEvent,
|
||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
|
StorageLocation,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ export interface ActiveDownload {
|
|||||||
state: "running" | "suspended" | "canceling" | "completed" | "unknown";
|
state: "running" | "suspended" | "canceling" | "completed" | "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StorageLocation {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
type: "internal" | "external";
|
||||||
|
label: string;
|
||||||
|
totalSpace: number;
|
||||||
|
freeSpace: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackgroundDownloaderModuleType {
|
export interface BackgroundDownloaderModuleType {
|
||||||
startDownload(url: string, destinationPath?: string): Promise<number>;
|
startDownload(url: string, destinationPath?: string): Promise<number>;
|
||||||
enqueueDownload(url: string, destinationPath?: string): Promise<number>;
|
enqueueDownload(url: string, destinationPath?: string): Promise<number>;
|
||||||
@@ -36,6 +45,7 @@ export interface BackgroundDownloaderModuleType {
|
|||||||
cancelQueuedDownload(url: string): void;
|
cancelQueuedDownload(url: string): void;
|
||||||
cancelAllDownloads(): void;
|
cancelAllDownloads(): void;
|
||||||
getActiveDownloads(): Promise<ActiveDownload[]>;
|
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||||
|
getAvailableStorageLocations(): Promise<StorageLocation[]>;
|
||||||
addListener(
|
addListener(
|
||||||
eventName: string,
|
eventName: string,
|
||||||
listener: (event: any) => void,
|
listener: (event: any) => void,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type {
|
|||||||
DownloadErrorEvent,
|
DownloadErrorEvent,
|
||||||
DownloadProgressEvent,
|
DownloadProgressEvent,
|
||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
|
StorageLocation,
|
||||||
} from "./background-downloader";
|
} from "./background-downloader";
|
||||||
// Background Downloader
|
// Background Downloader
|
||||||
export { default as BackgroundDownloader } from "./background-downloader";
|
export { default as BackgroundDownloader } from "./background-downloader";
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ public class VlcPlayerModule: Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Prop("nowPlayingMetadata") { (view: VlcPlayerView, metadata: [String: String]?) in
|
||||||
|
if let metadata = metadata {
|
||||||
|
view.setNowPlayingMetadata(metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Events(
|
Events(
|
||||||
"onPlaybackStateChanged",
|
"onPlaybackStateChanged",
|
||||||
"onVideoStateChange",
|
"onVideoStateChange",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
|
import MediaPlayer
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
import TVVLCKit
|
import TVVLCKit
|
||||||
@@ -24,6 +26,9 @@ class VlcPlayerView: ExpoView {
|
|||||||
var hasSource = false
|
var hasSource = false
|
||||||
var isTranscoding = false
|
var isTranscoding = false
|
||||||
private var initialSeekPerformed: Bool = false
|
private var initialSeekPerformed: Bool = false
|
||||||
|
private var nowPlayingMetadata: [String: String]?
|
||||||
|
private var artworkImage: UIImage?
|
||||||
|
private var artworkDownloadTask: URLSessionDataTask?
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
@@ -31,6 +36,8 @@ class VlcPlayerView: ExpoView {
|
|||||||
super.init(appContext: appContext)
|
super.init(appContext: appContext)
|
||||||
setupView()
|
setupView()
|
||||||
setupNotifications()
|
setupNotifications()
|
||||||
|
setupRemoteCommandCenter()
|
||||||
|
setupAudioSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
@@ -60,42 +67,205 @@ class VlcPlayerView: ExpoView {
|
|||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self, selector: #selector(applicationDidBecomeActive),
|
self, selector: #selector(applicationDidBecomeActive),
|
||||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self, selector: #selector(handleAudioSessionInterruption),
|
||||||
|
name: AVAudioSession.interruptionNotification, object: nil)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupAudioSession() {
|
||||||
|
#if !os(tvOS)
|
||||||
|
do {
|
||||||
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
|
try audioSession.setCategory(.playback, mode: .moviePlayback, options: [])
|
||||||
|
try audioSession.setActive(true)
|
||||||
|
print("Audio session configured for media controls")
|
||||||
|
} catch {
|
||||||
|
print("Failed to setup audio session: \(error)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupRemoteCommandCenter() {
|
||||||
|
#if !os(tvOS)
|
||||||
|
let commandCenter = MPRemoteCommandCenter.shared()
|
||||||
|
|
||||||
|
// Play command
|
||||||
|
commandCenter.playCommand.isEnabled = true
|
||||||
|
commandCenter.playCommand.addTarget { [weak self] _ in
|
||||||
|
self?.play()
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause command
|
||||||
|
commandCenter.pauseCommand.isEnabled = true
|
||||||
|
commandCenter.pauseCommand.addTarget { [weak self] _ in
|
||||||
|
self?.pause()
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle play/pause command
|
||||||
|
commandCenter.togglePlayPauseCommand.isEnabled = true
|
||||||
|
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||||
|
guard let self = self, let player = self.mediaPlayer else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
if player.isPlaying {
|
||||||
|
self.pause()
|
||||||
|
} else {
|
||||||
|
self.play()
|
||||||
|
}
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek forward command
|
||||||
|
commandCenter.skipForwardCommand.isEnabled = true
|
||||||
|
commandCenter.skipForwardCommand.preferredIntervals = [15]
|
||||||
|
commandCenter.skipForwardCommand.addTarget { [weak self] event in
|
||||||
|
guard let self = self, let player = self.mediaPlayer else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15
|
||||||
|
let currentTime = player.time.intValue
|
||||||
|
self.seekTo(currentTime + Int32(skipInterval * 1000))
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek backward command
|
||||||
|
commandCenter.skipBackwardCommand.isEnabled = true
|
||||||
|
commandCenter.skipBackwardCommand.preferredIntervals = [15]
|
||||||
|
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
|
||||||
|
guard let self = self, let player = self.mediaPlayer else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15
|
||||||
|
let currentTime = player.time.intValue
|
||||||
|
self.seekTo(max(0, currentTime - Int32(skipInterval * 1000)))
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change playback position command (scrubbing)
|
||||||
|
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
||||||
|
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||||
|
guard let self = self,
|
||||||
|
let event = event as? MPChangePlaybackPositionCommandEvent else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
let positionTime = event.positionTime
|
||||||
|
self.seekTo(Int32(positionTime * 1000))
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Remote command center configured")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupRemoteCommandCenter() {
|
||||||
|
#if !os(tvOS)
|
||||||
|
let commandCenter = MPRemoteCommandCenter.shared()
|
||||||
|
|
||||||
|
// Remove all command targets to prevent memory leaks
|
||||||
|
commandCenter.playCommand.removeTarget(nil)
|
||||||
|
commandCenter.pauseCommand.removeTarget(nil)
|
||||||
|
commandCenter.togglePlayPauseCommand.removeTarget(nil)
|
||||||
|
commandCenter.skipForwardCommand.removeTarget(nil)
|
||||||
|
commandCenter.skipBackwardCommand.removeTarget(nil)
|
||||||
|
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
||||||
|
|
||||||
|
// Disable commands
|
||||||
|
commandCenter.playCommand.isEnabled = false
|
||||||
|
commandCenter.pauseCommand.isEnabled = false
|
||||||
|
commandCenter.togglePlayPauseCommand.isEnabled = false
|
||||||
|
commandCenter.skipForwardCommand.isEnabled = false
|
||||||
|
commandCenter.skipBackwardCommand.isEnabled = false
|
||||||
|
commandCenter.changePlaybackPositionCommand.isEnabled = false
|
||||||
|
|
||||||
|
print("Remote command center cleaned up")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
func startPictureInPicture() {}
|
func startPictureInPicture() {}
|
||||||
|
|
||||||
@objc func play() {
|
@objc func play() {
|
||||||
self.mediaPlayer?.play()
|
DispatchQueue.main.async {
|
||||||
self.isPaused = false
|
self.mediaPlayer?.play()
|
||||||
print("Play")
|
self.isPaused = false
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
print("Play")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func pause() {
|
@objc func pause() {
|
||||||
self.mediaPlayer?.pause()
|
DispatchQueue.main.async {
|
||||||
self.isPaused = true
|
self.mediaPlayer?.pause()
|
||||||
|
self.isPaused = true
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func handleAudioSessionInterruption(_ notification: Notification) {
|
||||||
|
#if !os(tvOS)
|
||||||
|
guard let userInfo = notification.userInfo,
|
||||||
|
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .began:
|
||||||
|
// Interruption began - pause the video
|
||||||
|
print("Audio session interrupted - pausing video")
|
||||||
|
self.pause()
|
||||||
|
|
||||||
|
case .ended:
|
||||||
|
// Interruption ended - check if we should resume
|
||||||
|
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
|
||||||
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||||
|
if options.contains(.shouldResume) {
|
||||||
|
print("Audio session interruption ended - can resume")
|
||||||
|
// Don't auto-resume - let user manually resume playback
|
||||||
|
} else {
|
||||||
|
print("Audio session interruption ended - should not resume")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func seekTo(_ time: Int32) {
|
@objc func seekTo(_ time: Int32) {
|
||||||
guard let player = self.mediaPlayer else { return }
|
DispatchQueue.main.async {
|
||||||
|
guard let player = self.mediaPlayer else { return }
|
||||||
|
|
||||||
let wasPlaying = player.isPlaying
|
let wasPlaying = player.isPlaying
|
||||||
if wasPlaying {
|
|
||||||
self.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let duration = player.media?.length.intValue {
|
|
||||||
print("Seeking to time: \(time) Video Duration \(duration)")
|
|
||||||
|
|
||||||
// If the specified time is greater than the duration, seek to the end
|
|
||||||
let seekTime = time > duration ? duration - 1000 : time
|
|
||||||
player.time = VLCTime(int: seekTime)
|
|
||||||
if wasPlaying {
|
if wasPlaying {
|
||||||
self.play()
|
player.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let duration = player.media?.length.intValue {
|
||||||
|
print("Seeking to time: \(time) Video Duration \(duration)")
|
||||||
|
|
||||||
|
// If the specified time is greater than the duration, seek to the end
|
||||||
|
let seekTime = time > duration ? duration - 1000 : time
|
||||||
|
player.time = VLCTime(int: seekTime)
|
||||||
|
if wasPlaying {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
self.updatePlayerState()
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
} else {
|
||||||
|
print("Error: Unable to retrieve video duration")
|
||||||
}
|
}
|
||||||
self.updatePlayerState()
|
|
||||||
} else {
|
|
||||||
print("Error: Unable to retrieve video duration")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +433,55 @@ class VlcPlayerView: ExpoView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func setNowPlayingMetadata(_ metadata: [String: String]) {
|
||||||
|
// Cancel any existing artwork download to prevent race conditions
|
||||||
|
artworkDownloadTask?.cancel()
|
||||||
|
artworkDownloadTask = nil
|
||||||
|
|
||||||
|
self.nowPlayingMetadata = metadata
|
||||||
|
print("[NowPlaying] Metadata received: \(metadata)")
|
||||||
|
|
||||||
|
// Load artwork asynchronously if provided
|
||||||
|
if let artworkUri = metadata["artworkUri"], let url = URL(string: artworkUri) {
|
||||||
|
print("[NowPlaying] Loading artwork from: \(artworkUri)")
|
||||||
|
artworkDownloadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
if let error = error as NSError?, error.code == NSURLErrorCancelled {
|
||||||
|
print("[NowPlaying] Artwork download cancelled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
print("[NowPlaying] Artwork loading error: \(error)")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
}
|
||||||
|
} else if let data = data, let image = UIImage(data: data) {
|
||||||
|
print("[NowPlaying] Artwork loaded successfully, size: \(image.size)")
|
||||||
|
self.artworkImage = image
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("[NowPlaying] Failed to create image from data")
|
||||||
|
// Update Now Playing info without artwork on failure
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artworkDownloadTask?.resume()
|
||||||
|
} else {
|
||||||
|
// No artwork URI provided - update immediately
|
||||||
|
print("[NowPlaying] No artwork URI provided")
|
||||||
|
artworkImage = nil
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc func stop(completion: (() -> Void)? = nil) {
|
@objc func stop(completion: (() -> Void)? = nil) {
|
||||||
guard !isStopping else {
|
guard !isStopping else {
|
||||||
completion?()
|
completion?()
|
||||||
@@ -294,6 +513,27 @@ class VlcPlayerView: ExpoView {
|
|||||||
// Stop the media player
|
// Stop the media player
|
||||||
mediaPlayer?.stop()
|
mediaPlayer?.stop()
|
||||||
|
|
||||||
|
// Cancel any in-flight artwork downloads
|
||||||
|
artworkDownloadTask?.cancel()
|
||||||
|
artworkDownloadTask = nil
|
||||||
|
artworkImage = nil
|
||||||
|
|
||||||
|
// Cleanup remote command center targets
|
||||||
|
cleanupRemoteCommandCenter()
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
// Deactivate audio session to allow other apps to use audio
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
print("Audio session deactivated")
|
||||||
|
} catch {
|
||||||
|
print("Failed to deactivate audio session: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear Now Playing info
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||||
|
#endif
|
||||||
|
|
||||||
// Remove observer
|
// Remove observer
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
|
||||||
@@ -327,6 +567,60 @@ class VlcPlayerView: ExpoView {
|
|||||||
"duration": durationMs,
|
"duration": durationMs,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update Now Playing info to sync elapsed playback time
|
||||||
|
// iOS needs periodic updates to keep progress indicator in sync
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateNowPlayingInfo() {
|
||||||
|
#if !os(tvOS)
|
||||||
|
guard let player = self.mediaPlayer else { return }
|
||||||
|
|
||||||
|
var nowPlayingInfo = [String: Any]()
|
||||||
|
|
||||||
|
// Playback rate (0.0 = paused, 1.0 = playing at normal speed)
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.isPlaying ? player.rate : 0.0
|
||||||
|
|
||||||
|
// Current playback time in seconds
|
||||||
|
let currentTimeSeconds = Double(player.time.intValue) / 1000.0
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTimeSeconds
|
||||||
|
|
||||||
|
// Total duration in seconds
|
||||||
|
if let duration = player.media?.length.intValue {
|
||||||
|
let durationSeconds = Double(duration) / 1000.0
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = durationSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add metadata if available
|
||||||
|
if let metadata = self.nowPlayingMetadata {
|
||||||
|
if let title = metadata["title"] {
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyTitle] = title
|
||||||
|
print("[NowPlaying] Setting title: \(title)")
|
||||||
|
}
|
||||||
|
if let artist = metadata["artist"] {
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyArtist] = artist
|
||||||
|
print("[NowPlaying] Setting artist: \(artist)")
|
||||||
|
}
|
||||||
|
if let albumTitle = metadata["albumTitle"] {
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = albumTitle
|
||||||
|
print("[NowPlaying] Setting album: \(albumTitle)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add artwork if available
|
||||||
|
if let artwork = self.artworkImage {
|
||||||
|
print("[NowPlaying] Setting artwork with size: \(artwork.size)")
|
||||||
|
let artworkItem = MPMediaItemArtwork(boundsSize: artwork.size) { _ in
|
||||||
|
return artwork
|
||||||
|
}
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyArtwork] = artworkItem
|
||||||
|
}
|
||||||
|
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Expo Events
|
// MARK: - Expo Events
|
||||||
|
|||||||
26
package.json
26
package.json
@@ -68,7 +68,7 @@
|
|||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "16.0.0",
|
||||||
"react-native": "npm:react-native-tvos@0.81.5-1",
|
"react-native": "npm:react-native-tvos@0.81.5-1",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "^1.0.2",
|
"react-native-bottom-tabs": "^1.0.2",
|
||||||
@@ -103,20 +103,20 @@
|
|||||||
"zod": "^4.1.3"
|
"zod": "^4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
"@babel/core": "7.28.5",
|
||||||
"@biomejs/biome": "^2.3.5",
|
"@biomejs/biome": "2.3.5",
|
||||||
"@react-native-community/cli": "^20.0.2",
|
"@react-native-community/cli": "20.0.2",
|
||||||
"@react-native-tvos/config-tv": "^0.1.4",
|
"@react-native-tvos/config-tv": "0.1.4",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "^4.17.20",
|
"@types/lodash": "4.17.20",
|
||||||
"@types/react": "~19.1.10",
|
"@types/react": "~19.1.10",
|
||||||
"@types/react-test-renderer": "^19.0.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "^1.17.11",
|
"expo-doctor": "1.17.11",
|
||||||
"husky": "^9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "^16.2.6",
|
"lint-staged": "16.2.6",
|
||||||
"react-test-renderer": "19.1.1",
|
"react-test-renderer": "19.1.1",
|
||||||
"typescript": "~5.9.3"
|
"typescript": "5.9.3"
|
||||||
},
|
},
|
||||||
"expo": {
|
"expo": {
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -6,16 +6,20 @@ import type {
|
|||||||
import { Directory, File, Paths } from "expo-file-system";
|
import { Directory, File, Paths } from "expo-file-system";
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import { fetchAndParseSegments } from "@/utils/segments";
|
import { fetchAndParseSegments } from "@/utils/segments";
|
||||||
|
import { filePathToUri } from "@/utils/storage";
|
||||||
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
|
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
|
||||||
import type { MediaTimeSegment, TrickPlayData } from "./types";
|
import type { MediaTimeSegment, TrickPlayData } from "./types";
|
||||||
import { generateFilename } from "./utils";
|
import { generateFilename } from "./utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads trickplay images for an item
|
* Downloads trickplay images for an item
|
||||||
|
* @param item - The item to download trickplay images for
|
||||||
|
* @param storagePath - Optional custom storage path (for Android SD card support)
|
||||||
* @returns TrickPlayData with path and size, or undefined if not available
|
* @returns TrickPlayData with path and size, or undefined if not available
|
||||||
*/
|
*/
|
||||||
export async function downloadTrickplayImages(
|
export async function downloadTrickplayImages(
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
|
storagePath?: string,
|
||||||
): Promise<TrickPlayData | undefined> {
|
): Promise<TrickPlayData | undefined> {
|
||||||
const trickplayInfo = getTrickplayInfo(item);
|
const trickplayInfo = getTrickplayInfo(item);
|
||||||
if (!trickplayInfo || !item.Id) {
|
if (!trickplayInfo || !item.Id) {
|
||||||
@@ -23,7 +27,11 @@ export async function downloadTrickplayImages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filename = generateFilename(item);
|
const filename = generateFilename(item);
|
||||||
const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`);
|
|
||||||
|
// Use custom storage path if provided (Android SD card), otherwise use Paths.document
|
||||||
|
const trickplayDir = storagePath
|
||||||
|
? new Directory(filePathToUri(storagePath), `${filename}_trickplay`)
|
||||||
|
: new Directory(Paths.document, `${filename}_trickplay`);
|
||||||
|
|
||||||
// Create directory if it doesn't exist
|
// Create directory if it doesn't exist
|
||||||
if (!trickplayDir.exists) {
|
if (!trickplayDir.exists) {
|
||||||
@@ -69,12 +77,17 @@ export async function downloadTrickplayImages(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads external subtitle files and updates their delivery URLs to local paths
|
* Downloads external subtitle files and updates their delivery URLs to local paths
|
||||||
|
* @param mediaSource - The media source containing subtitle information
|
||||||
|
* @param item - The item to download subtitles for
|
||||||
|
* @param apiBasePath - The base path for the API
|
||||||
|
* @param storagePath - Optional custom storage path (for Android SD card support)
|
||||||
* @returns Updated media source with local subtitle paths
|
* @returns Updated media source with local subtitle paths
|
||||||
*/
|
*/
|
||||||
export async function downloadSubtitles(
|
export async function downloadSubtitles(
|
||||||
mediaSource: MediaSourceInfo,
|
mediaSource: MediaSourceInfo,
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
apiBasePath: string,
|
apiBasePath: string,
|
||||||
|
storagePath?: string,
|
||||||
): Promise<MediaSourceInfo> {
|
): Promise<MediaSourceInfo> {
|
||||||
const externalSubtitles = mediaSource.MediaStreams?.filter(
|
const externalSubtitles = mediaSource.MediaStreams?.filter(
|
||||||
(stream) =>
|
(stream) =>
|
||||||
@@ -91,10 +104,17 @@ export async function downloadSubtitles(
|
|||||||
|
|
||||||
const url = apiBasePath + subtitle.DeliveryUrl;
|
const url = apiBasePath + subtitle.DeliveryUrl;
|
||||||
const extension = subtitle.Codec || "srt";
|
const extension = subtitle.Codec || "srt";
|
||||||
const destination = new File(
|
|
||||||
Paths.document,
|
// Use custom storage path if provided (Android SD card), otherwise use Paths.document
|
||||||
`${filename}_subtitle_${subtitle.Index}.${extension}`,
|
const destination = storagePath
|
||||||
);
|
? new File(
|
||||||
|
filePathToUri(storagePath),
|
||||||
|
`${filename}_subtitle_${subtitle.Index}.${extension}`,
|
||||||
|
)
|
||||||
|
: new File(
|
||||||
|
Paths.document,
|
||||||
|
`${filename}_subtitle_${subtitle.Index}.${extension}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Skip if already exists
|
// Skip if already exists
|
||||||
if (destination.exists) {
|
if (destination.exists) {
|
||||||
@@ -208,13 +228,21 @@ export async function downloadAdditionalAssets(params: {
|
|||||||
api: Api;
|
api: Api;
|
||||||
saveImageFn: (itemId: string, url?: string) => Promise<void>;
|
saveImageFn: (itemId: string, url?: string) => Promise<void>;
|
||||||
saveSeriesImageFn: (item: BaseItemDto) => Promise<void>;
|
saveSeriesImageFn: (item: BaseItemDto) => Promise<void>;
|
||||||
|
storagePath?: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
trickPlayData?: TrickPlayData;
|
trickPlayData?: TrickPlayData;
|
||||||
updatedMediaSource: MediaSourceInfo;
|
updatedMediaSource: MediaSourceInfo;
|
||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
creditSegments?: MediaTimeSegment[];
|
creditSegments?: MediaTimeSegment[];
|
||||||
}> {
|
}> {
|
||||||
const { item, mediaSource, api, saveImageFn, saveSeriesImageFn } = params;
|
const {
|
||||||
|
item,
|
||||||
|
mediaSource,
|
||||||
|
api,
|
||||||
|
saveImageFn,
|
||||||
|
saveSeriesImageFn,
|
||||||
|
storagePath,
|
||||||
|
} = params;
|
||||||
|
|
||||||
// Run all downloads in parallel for speed
|
// Run all downloads in parallel for speed
|
||||||
const [
|
const [
|
||||||
@@ -223,11 +251,11 @@ export async function downloadAdditionalAssets(params: {
|
|||||||
segments,
|
segments,
|
||||||
// Cover images (fire and forget, errors are logged)
|
// Cover images (fire and forget, errors are logged)
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
downloadTrickplayImages(item),
|
downloadTrickplayImages(item, storagePath),
|
||||||
// Only download subtitles for non-transcoded streams
|
// Only download subtitles for non-transcoded streams
|
||||||
mediaSource.TranscodingUrl
|
mediaSource.TranscodingUrl
|
||||||
? Promise.resolve(mediaSource)
|
? Promise.resolve(mediaSource)
|
||||||
: downloadSubtitles(mediaSource, item, api.basePath || ""),
|
: downloadSubtitles(mediaSource, item, api.basePath || "", storagePath),
|
||||||
item.Id
|
item.Id
|
||||||
? fetchSegments(item.Id, api)
|
? fetchSegments(item.Id, api)
|
||||||
: Promise.resolve({
|
: Promise.resolve({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Directory, File, Paths } from "expo-file-system";
|
import { Directory, File } from "expo-file-system";
|
||||||
import { getAllDownloadedItems, getDownloadedItemById } from "./database";
|
import { getAllDownloadedItems, getDownloadedItemById } from "./database";
|
||||||
import type { DownloadedItem } from "./types";
|
import type { DownloadedItem } from "./types";
|
||||||
import { filePathToUri } from "./utils";
|
import { filePathToUri } from "./utils";
|
||||||
@@ -39,13 +39,11 @@ export function deleteAllAssociatedFiles(item: DownloadedItem): void {
|
|||||||
stream.DeliveryUrl
|
stream.DeliveryUrl
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const subtitleFilename = stream.DeliveryUrl.split("/").pop();
|
// Use the full path from DeliveryUrl (it's already a full file:// URI)
|
||||||
if (subtitleFilename) {
|
const subtitleFile = new File(stream.DeliveryUrl);
|
||||||
const subtitleFile = new File(Paths.document, subtitleFilename);
|
if (subtitleFile.exists) {
|
||||||
if (subtitleFile.exists) {
|
subtitleFile.delete();
|
||||||
subtitleFile.delete();
|
console.log(`[DELETE] Subtitle deleted: ${stream.DeliveryUrl}`);
|
||||||
console.log(`[DELETE] Subtitle deleted: ${subtitleFilename}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[DELETE] Failed to delete subtitle:", error);
|
console.error("[DELETE] Failed to delete subtitle:", error);
|
||||||
@@ -57,15 +55,13 @@ export function deleteAllAssociatedFiles(item: DownloadedItem): void {
|
|||||||
// Delete trickplay directory
|
// Delete trickplay directory
|
||||||
if (item.trickPlayData?.path) {
|
if (item.trickPlayData?.path) {
|
||||||
try {
|
try {
|
||||||
const trickplayDirName = item.trickPlayData.path.split("/").pop();
|
// Use the full path from trickPlayData (it's already a full file:// URI)
|
||||||
if (trickplayDirName) {
|
const trickplayDir = new Directory(item.trickPlayData.path);
|
||||||
const trickplayDir = new Directory(Paths.document, trickplayDirName);
|
if (trickplayDir.exists) {
|
||||||
if (trickplayDir.exists) {
|
trickplayDir.delete();
|
||||||
trickplayDir.delete();
|
console.log(
|
||||||
console.log(
|
`[DELETE] Trickplay directory deleted: ${item.trickPlayData.path}`,
|
||||||
`[DELETE] Trickplay directory deleted: ${trickplayDirName}`,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[DELETE] Failed to delete trickplay directory:", error);
|
console.error("[DELETE] Failed to delete trickplay directory:", error);
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import { File, Paths } from "expo-file-system";
|
|||||||
import type { MutableRefObject } from "react";
|
import type { MutableRefObject } from "react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import DeviceInfo from "react-native-device-info";
|
import DeviceInfo from "react-native-device-info";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import type { Bitrate } from "@/components/BitrateSelector";
|
import type { Bitrate } from "@/components/BitrateSelector";
|
||||||
import useImageStorage from "@/hooks/useImageStorage";
|
import useImageStorage from "@/hooks/useImageStorage";
|
||||||
import { BackgroundDownloader } from "@/modules";
|
import { BackgroundDownloader } from "@/modules";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getOrSetDeviceId } from "@/utils/device";
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
import useDownloadHelper from "@/utils/download";
|
import useDownloadHelper from "@/utils/download";
|
||||||
|
import { getStoragePath } from "@/utils/storage";
|
||||||
import { downloadAdditionalAssets } from "../additionalDownloads";
|
import { downloadAdditionalAssets } from "../additionalDownloads";
|
||||||
import {
|
import {
|
||||||
clearAllDownloadedItems,
|
clearAllDownloadedItems,
|
||||||
@@ -49,6 +52,7 @@ export function useDownloadOperations({
|
|||||||
onDataChange,
|
onDataChange,
|
||||||
}: UseDownloadOperationsProps) {
|
}: UseDownloadOperationsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||||
const { saveImage } = useImageStorage();
|
const { saveImage } = useImageStorage();
|
||||||
|
|
||||||
@@ -79,6 +83,12 @@ export function useDownloadOperations({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get storage path if custom location is set
|
||||||
|
let storagePath: string | undefined;
|
||||||
|
if (Platform.OS === "android" && settings.downloadStorageLocation) {
|
||||||
|
storagePath = await getStoragePath(settings.downloadStorageLocation);
|
||||||
|
}
|
||||||
|
|
||||||
// Download all additional assets BEFORE starting native video download
|
// Download all additional assets BEFORE starting native video download
|
||||||
const additionalAssets = await downloadAdditionalAssets({
|
const additionalAssets = await downloadAdditionalAssets({
|
||||||
item,
|
item,
|
||||||
@@ -86,6 +96,7 @@ export function useDownloadOperations({
|
|||||||
api,
|
api,
|
||||||
saveImageFn: saveImage,
|
saveImageFn: saveImage,
|
||||||
saveSeriesImageFn: saveSeriesPrimaryImage,
|
saveSeriesImageFn: saveSeriesPrimaryImage,
|
||||||
|
storagePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure URL is absolute (not relative) before storing
|
// Ensure URL is absolute (not relative) before storing
|
||||||
@@ -119,10 +130,19 @@ export function useDownloadOperations({
|
|||||||
// Add to processes
|
// Add to processes
|
||||||
setProcesses((prev) => [...prev, jobStatus]);
|
setProcesses((prev) => [...prev, jobStatus]);
|
||||||
|
|
||||||
// Generate destination path
|
// Generate destination path using custom storage location if set
|
||||||
const filename = generateFilename(item);
|
const filename = generateFilename(item);
|
||||||
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
let destinationPath: string;
|
||||||
const destinationPath = uriToFilePath(videoFile.uri);
|
|
||||||
|
if (storagePath) {
|
||||||
|
// Use custom storage location
|
||||||
|
destinationPath = `${storagePath}/${filename}.mp4`;
|
||||||
|
console.log(`[DOWNLOAD] Using custom storage: ${destinationPath}`);
|
||||||
|
} else {
|
||||||
|
// Use default Paths.document
|
||||||
|
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
||||||
|
destinationPath = uriToFilePath(videoFile.uri);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[DOWNLOAD] Starting video: ${item.Name}`);
|
console.log(`[DOWNLOAD] Starting video: ${item.Name}`);
|
||||||
console.log(`[DOWNLOAD] Download URL: ${downloadUrl}`);
|
console.log(`[DOWNLOAD] Download URL: ${downloadUrl}`);
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.47.1" },
|
clientInfo: { name: "Streamyfin", version: "0.48.0" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.47.1"`,
|
}, DeviceId="${deviceId}", Version="0.48.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -13,30 +13,41 @@ const disableForTV = (_moduleName) =>
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
module.exports = {
|
const dependencies = {
|
||||||
dependencies: {
|
"react-native-volume-manager": !isTV
|
||||||
"react-native-volume-manager": !isTV
|
? {
|
||||||
? {
|
platforms: {
|
||||||
platforms: {
|
// leaving this blank seems to enable auto-linking which is what we want for mobile
|
||||||
// leaving this blank seems to enable auto-linking which is what we want for mobile
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
platforms: {
|
|
||||||
android: null,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"expo-notifications": disableForTV("expo-notifications"),
|
}
|
||||||
"react-native-image-colors": disableForTV("react-native-image-colors"),
|
: {
|
||||||
"expo-sharing": disableForTV("expo-sharing"),
|
platforms: {
|
||||||
"expo-haptics": disableForTV("expo-haptics"),
|
android: null,
|
||||||
"expo-brightness": disableForTV("expo-brightness"),
|
},
|
||||||
"expo-sensors": disableForTV("expo-sensors"),
|
},
|
||||||
"expo-screen-orientation": disableForTV("expo-screen-orientation"),
|
"expo-notifications": disableForTV("expo-notifications"),
|
||||||
"react-native-ios-context-menu": disableForTV(
|
"react-native-image-colors": disableForTV("react-native-image-colors"),
|
||||||
"react-native-ios-context-menu",
|
"expo-sharing": disableForTV("expo-sharing"),
|
||||||
),
|
"expo-haptics": disableForTV("expo-haptics"),
|
||||||
"react-native-ios-utilities": disableForTV("react-native-ios-utilities"),
|
"expo-brightness": disableForTV("expo-brightness"),
|
||||||
"react-native-pager-view": disableForTV("react-native-pager-view"),
|
"expo-sensors": disableForTV("expo-sensors"),
|
||||||
|
"expo-screen-orientation": disableForTV("expo-screen-orientation"),
|
||||||
|
"react-native-ios-context-menu": disableForTV(
|
||||||
|
"react-native-ios-context-menu",
|
||||||
|
),
|
||||||
|
"react-native-ios-utilities": disableForTV("react-native-ios-utilities"),
|
||||||
|
"react-native-pager-view": disableForTV("react-native-pager-view"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter out undefined values
|
||||||
|
const cleanDependencies = Object.fromEntries(
|
||||||
|
Object.entries(dependencies).filter(([_, value]) => value !== undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dependencies: cleanDependencies,
|
||||||
|
project: {
|
||||||
|
ios: {},
|
||||||
|
android: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -337,7 +337,8 @@
|
|||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitle": "Subtitle",
|
"subtitle": "Subtitle",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"none": "None"
|
"none": "None",
|
||||||
|
"track": "Track"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
@@ -444,6 +445,7 @@
|
|||||||
"no_similar_items_found": "No Similar Items Found",
|
"no_similar_items_found": "No Similar Items Found",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"more_details": "More Details",
|
"more_details": "More Details",
|
||||||
|
"media_options": "Media Options",
|
||||||
"quality": "Quality",
|
"quality": "Quality",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": "Subtitle",
|
"subtitles": "Subtitle",
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export type Settings = {
|
|||||||
marlinServerUrl?: string;
|
marlinServerUrl?: string;
|
||||||
openInVLC?: boolean;
|
openInVLC?: boolean;
|
||||||
downloadQuality?: DownloadOption;
|
downloadQuality?: DownloadOption;
|
||||||
|
downloadStorageLocation?: string;
|
||||||
defaultBitrate?: Bitrate;
|
defaultBitrate?: Bitrate;
|
||||||
libraryOptions: LibraryOptions;
|
libraryOptions: LibraryOptions;
|
||||||
defaultAudioLanguage: CultureDto | null;
|
defaultAudioLanguage: CultureDto | null;
|
||||||
@@ -203,6 +204,7 @@ export const defaultValues: Settings = {
|
|||||||
marlinServerUrl: "",
|
marlinServerUrl: "",
|
||||||
openInVLC: false,
|
openInVLC: false,
|
||||||
downloadQuality: DownloadOptions[0],
|
downloadQuality: DownloadOptions[0],
|
||||||
|
downloadStorageLocation: undefined,
|
||||||
defaultBitrate: BITRATES[0],
|
defaultBitrate: BITRATES[0],
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
display: "list",
|
display: "list",
|
||||||
|
|||||||
Submodule utils/jellyseerr updated: fc6a9e952c...4401b16414
143
utils/storage.ts
Normal file
143
utils/storage.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { Directory, Paths } from "expo-file-system";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { BackgroundDownloader, type StorageLocation } from "@/modules";
|
||||||
|
|
||||||
|
let cachedStorageLocations: StorageLocation[] | null = null;
|
||||||
|
|
||||||
|
// Debug mode: Set to true to simulate an SD card for testing in emulator
|
||||||
|
// This creates a real writable directory that mimics SD card behavior
|
||||||
|
const DEBUG_SIMULATE_SD_CARD = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available storage locations (Android only)
|
||||||
|
* Returns cached result on subsequent calls
|
||||||
|
*/
|
||||||
|
export async function getAvailableStorageLocations(): Promise<
|
||||||
|
StorageLocation[]
|
||||||
|
> {
|
||||||
|
if (Platform.OS !== "android") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedStorageLocations !== null) {
|
||||||
|
return cachedStorageLocations;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const locations = await BackgroundDownloader.getAvailableStorageLocations();
|
||||||
|
|
||||||
|
// Debug mode: Add a functional simulated SD card for testing
|
||||||
|
if (DEBUG_SIMULATE_SD_CARD && locations.length === 1) {
|
||||||
|
// Use a real writable path within the app's document directory
|
||||||
|
const sdcardSimDir = new Directory(Paths.document, "sdcard_sim");
|
||||||
|
|
||||||
|
// Create the directory if it doesn't exist
|
||||||
|
if (!sdcardSimDir.exists) {
|
||||||
|
sdcardSimDir.create({ intermediates: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockSdCard: StorageLocation = {
|
||||||
|
id: "sdcard_sim",
|
||||||
|
path: sdcardSimDir.uri.replace("file://", ""),
|
||||||
|
type: "external",
|
||||||
|
label: "SD Card (Simulated)",
|
||||||
|
totalSpace: 64 * 1024 * 1024 * 1024, // 64 GB
|
||||||
|
freeSpace: 32 * 1024 * 1024 * 1024, // 32 GB free
|
||||||
|
};
|
||||||
|
locations.push(mockSdCard);
|
||||||
|
console.log("[DEBUG] Added simulated SD card:", mockSdCard.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedStorageLocations = locations;
|
||||||
|
return locations;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get storage locations:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cached storage locations
|
||||||
|
* Useful when storage configuration might have changed
|
||||||
|
*/
|
||||||
|
export function clearStorageLocationsCache(): void {
|
||||||
|
cachedStorageLocations = null;
|
||||||
|
console.log("[Storage] Cache cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a simplified label for a storage location ID
|
||||||
|
* @param storageId - The storage location ID (e.g., "internal", "sdcard_0")
|
||||||
|
* @returns Human-readable label (e.g., "Internal Storage", "SD Card")
|
||||||
|
*/
|
||||||
|
export async function getStorageLabel(storageId?: string): Promise<string> {
|
||||||
|
if (!storageId || Platform.OS !== "android") {
|
||||||
|
return "Internal Storage";
|
||||||
|
}
|
||||||
|
|
||||||
|
const locations = await getAvailableStorageLocations();
|
||||||
|
const location = locations.find((loc) => loc.id === storageId);
|
||||||
|
|
||||||
|
return location?.label || "Internal Storage";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the filesystem path for a storage location ID
|
||||||
|
* @param storageId - The storage location ID (e.g., "internal", "sdcard_0")
|
||||||
|
* @returns The filesystem path, or default path if not found
|
||||||
|
*/
|
||||||
|
export async function getStoragePath(storageId?: string): Promise<string> {
|
||||||
|
if (!storageId || Platform.OS !== "android") {
|
||||||
|
return getDefaultStoragePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
const locations = await getAvailableStorageLocations();
|
||||||
|
const location = locations.find((loc) => loc.id === storageId);
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
console.warn(`Storage location not found: ${storageId}, using default`);
|
||||||
|
return getDefaultStoragePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
return location.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default storage path (current behavior using Paths.document)
|
||||||
|
* @returns The default storage path
|
||||||
|
*/
|
||||||
|
export function getDefaultStoragePath(): string {
|
||||||
|
// Paths.document returns a Directory with a URI like "file:///data/user/0/..."
|
||||||
|
// We need to extract the actual path
|
||||||
|
const uri = Paths.document.uri;
|
||||||
|
return uri.replace("file://", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a storage location by ID
|
||||||
|
* @param storageId - The storage location ID
|
||||||
|
* @returns The storage location or undefined if not found
|
||||||
|
*/
|
||||||
|
export async function getStorageLocationById(
|
||||||
|
storageId?: string,
|
||||||
|
): Promise<StorageLocation | undefined> {
|
||||||
|
if (!storageId || Platform.OS !== "android") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locations = await getAvailableStorageLocations();
|
||||||
|
return locations.find((loc) => loc.id === storageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert plain file path to file:// URI
|
||||||
|
* Required for expo-file-system File constructor
|
||||||
|
* @param path - The file path
|
||||||
|
* @returns The file:// URI
|
||||||
|
*/
|
||||||
|
export function filePathToUri(path: string): string {
|
||||||
|
if (path.startsWith("file://")) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return `file://${path}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user