diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml
index 8548466..ca721b4 100644
--- a/.github/workflows/main.yaml
+++ b/.github/workflows/main.yaml
@@ -82,6 +82,9 @@ jobs:
CI: true
EXPO_NO_TELEMETRY: 1
+ - name: đ§Ș Run expo prebuild
+ run: pnpm exec expo prebuild
+
- name: đ§Ș Run TypeScript check
run: pnpm tsc
diff --git a/.gitignore b/.gitignore
index f630cb2..0f24c5e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,4 +23,7 @@ expo-env.d.ts
.metro-health-check*
.env.*
-!.env.example
\ No newline at end of file
+!.env.example
+
+# Generated web UI embed
+src/api/local-server/web-ui.ts
diff --git a/app.config.ts b/app.config.ts
index acb060d..cea09b2 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -1,6 +1,7 @@
+// Register ts-node so Expo can evaluate this ESM config file at prebuild time.
import 'tsx/cjs';
-import { ExpoConfig, ConfigContext } from 'expo/config';
-const packageJson = require('./package.json');
+import type { ExpoConfig, ConfigContext } from 'expo/config';
+import packageJson from './package.json' with { type: 'json' };
export default ({ config }: ConfigContext): ExpoConfig => {
const appBackgroundColor = '#181A20';
@@ -85,6 +86,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
project: process.env.SENTRY_PROJECT ?? 'dodostream',
},
],
+ './plugins/withRemoteUIBuild',
'./plugins/withReactNativeTVOSPnpmFix',
'./plugins/withAndroidBuildOptimizations',
'./plugins/withAndroidConfigChanges',
@@ -118,6 +120,9 @@ export default ({ config }: ConfigContext): ExpoConfig => {
permissions: [
'android.permission.FOREGROUND_SERVICE',
'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK',
+ 'android.permission.INTERNET',
+ 'android.permission.ACCESS_NETWORK_STATE',
+ 'android.permission.ACCESS_WIFI_STATE'
],
},
extra: {
diff --git a/eslint.config.js b/eslint.config.js
index 6921096..c08957e 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -7,7 +7,7 @@ module.exports = defineConfig([
expoConfig,
reactCompiler.configs.recommended,
{
- ignores: ['dist/*', '.expo/**'],
+ ignores: ['dist/*', '.expo/**', 'remote-ui/**'],
},
{
rules: {
diff --git a/package.json b/package.json
index f0a8192..071df09 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"build:production_apk:local": "eas build --profile production_apk --local",
"build:production_apk:tv": "eas build --profile production_tv_apk",
"build:production_apk:tv:local": "eas build --profile production_tv_apk --local",
+ "build:ui": "pnpm --filter remote-ui build && node scripts/embed-web-ui.mjs",
"prebuild": "expo prebuild",
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\"",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
@@ -56,6 +57,7 @@
"expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.12",
"expo-localization": "~17.0.8",
+ "expo-network": "~8.0.8",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",
@@ -71,6 +73,8 @@
"react-native": "npm:react-native-tvos@0.81-stable",
"react-native-gesture-handler": "~2.28.0",
"react-native-immersive-mode": "^2.0.2",
+ "react-native-nitro-http-server": "^1.8.1",
+ "react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "~4.1.6",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.16.0",
@@ -101,6 +105,7 @@
"jest-expo": "^54.0.17",
"prettier": "^3.2.5",
"react-test-renderer": "19.1.0",
+ "ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "~5.9.2"
},
diff --git a/plugins/withRemoteUIBuild.ts b/plugins/withRemoteUIBuild.ts
new file mode 100644
index 0000000..cb64642
--- /dev/null
+++ b/plugins/withRemoteUIBuild.ts
@@ -0,0 +1,56 @@
+import { ConfigPlugin, withDangerousMod } from 'expo/config-plugins';
+import { execSync } from 'child_process';
+import path from 'path';
+import fs from 'fs';
+
+/**
+ * Expo config plugin that builds the remote-ui SPA and embeds it
+ * into src/api/local-server/web-ui.ts before native project generation.
+ *
+ * Registered on both platforms so it runs regardless of which platform
+ * is being prebuilt. A module-level flag ensures the build only executes once
+ * when both platforms are built together.
+ *
+ * Runs: pnpm --filter remote-ui build && node scripts/embed-web-ui.mjs
+ */
+
+let built = false;
+
+function buildRemoteUI(projectRoot: string): void {
+ if (built) return;
+ built = true;
+
+ const webUiPath = path.join(projectRoot, 'src/api/local-server/web-ui.ts');
+
+ console.log('đ Building remote-ui SPA...');
+ try {
+ execSync('pnpm --filter remote-ui build', { cwd: projectRoot, stdio: 'inherit' });
+ execSync('node scripts/embed-web-ui.mjs', { cwd: projectRoot, stdio: 'inherit' });
+ console.log('â Remote UI embedded successfully');
+ } catch (error) {
+ console.error('â ïž Failed to build remote-ui:', error);
+ // Don't fail the prebuild â write a stub if the file is missing
+ if (!fs.existsSync(webUiPath)) {
+ fs.writeFileSync(
+ webUiPath,
+ "// AUTO-GENERATED â do not edit. Run pnpm build:ui to regenerate.\nexport const WEB_UI_HTML = '';\n",
+ 'utf-8'
+ );
+ }
+ }
+}
+
+const withRemoteUIBuild: ConfigPlugin = (config) => {
+ for (const platform of ['ios', 'android'] as const) {
+ config = withDangerousMod(config, [
+ platform,
+ (modConfig) => {
+ buildRemoteUI(modConfig.modRequest.projectRoot);
+ return modConfig;
+ },
+ ]);
+ }
+ return config;
+};
+
+export default withRemoteUIBuild;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 73d030a..f356a39 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -100,9 +100,12 @@ importers:
expo-localization:
specifier: ~17.0.8
version: 17.0.8(expo@54.0.34)(react@19.1.0)
+ expo-network:
+ specifier: ~8.0.8
+ version: 8.0.8(expo@54.0.34)(react@19.1.0)
expo-router:
specifier: ~6.0.23
- version: 6.0.23(9762b74b39ac1ba59cbc6550e182d2ca)
+ version: 6.0.23(d3775fd364ea69d962a61ae45d7a67fd)
expo-splash-screen:
specifier: ~31.0.13
version: 31.0.13(expo@54.0.34)
@@ -123,7 +126,7 @@ importers:
version: 15.0.11(expo@54.0.34)(react-native-tvos@0.81.5-1)
i18next:
specifier: ^26.0.6
- version: 26.0.6(typescript@5.9.3)
+ version: 26.0.8(typescript@5.9.3)
moti:
specifier: ^0.30.0
version: 0.30.0(react-dom@19.1.0(react@19.1.0))(react-native-reanimated@4.1.6(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react@19.1.0))(react@19.1.0))(react@19.1.0)
@@ -135,7 +138,7 @@ importers:
version: 19.1.0(react@19.1.0)
react-i18next:
specifier: ^17.0.4
- version: 17.0.4(i18next@26.0.6(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)(typescript@5.9.3)
+ version: 17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)(typescript@5.9.3)
react-native:
specifier: npm:react-native-tvos@0.81-stable
version: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0)
@@ -145,6 +148,12 @@ importers:
react-native-immersive-mode:
specifier: ^2.0.2
version: 2.0.2(react-native-tvos@0.81.5-1)
+ react-native-nitro-http-server:
+ specifier: ^1.8.1
+ version: 1.8.1(react-native-nitro-modules@0.35.5(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)
+ react-native-qrcode-svg:
+ specifier: ^6.3.21
+ version: 6.3.21(react-native-svg@15.12.1(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)
react-native-reanimated:
specifier: ~4.1.6
version: 4.1.6(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react@19.1.0))(react@19.1.0)
@@ -181,7 +190,7 @@ importers:
version: 3.0.3
'@testing-library/react-native':
specifier: ^13.3.3
- version: 13.3.3(jest@29.7.0(@types/node@25.0.9))(react-native-tvos@0.81.5-1)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
+ version: 13.3.3(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)))(react-native-tvos@0.81.5-1)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
'@types/jest':
specifier: ^29.5.14
version: 29.5.14
@@ -199,37 +208,40 @@ importers:
version: 6.9.4
babel-preset-expo:
specifier: ~54.0.10
- version: 54.0.10(@babel/core@7.28.5)(@babel/runtime@7.29.2)(expo@54.0.34)(react-refresh@0.14.2)
+ version: 54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.34)(react-refresh@0.17.0)
drizzle-kit:
specifier: ^0.31.9
version: 0.31.9
eslint:
specifier: ^9.25.1
- version: 9.39.1(jiti@1.21.7)
+ version: 9.39.1(jiti@2.6.1)
eslint-config-expo:
specifier: ~10.0.0
- version: 10.0.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
+ version: 10.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
eslint-config-prettier:
specifier: ^10.1.2
- version: 10.1.8(eslint@9.39.1(jiti@1.21.7))
+ version: 10.1.8(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-react-compiler:
specifier: 19.1.0-rc.2
- version: 19.1.0-rc.2(eslint@9.39.1(jiti@1.21.7))
+ version: 19.1.0-rc.2(eslint@9.39.1(jiti@2.6.1))
expo-sqlite-mock:
specifier: ^3.0.2
- version: 3.0.2(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0))(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9))
+ version: 3.0.2(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0))(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)))
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.0.9)
+ version: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))
jest-expo:
specifier: ^54.0.17
- version: 54.0.17(@babel/core@7.28.5)(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9))(react-native-tvos@0.81.5-1)(react@19.1.0)
+ version: 54.0.17(@babel/core@7.28.5)(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)))(react-native-tvos@0.81.5-1)(react@19.1.0)
prettier:
specifier: ^3.2.5
version: 3.7.4
react-test-renderer:
specifier: 19.1.0
version: 19.1.0(react@19.1.0)
+ ts-node:
+ specifier: ^10.9.2
+ version: 10.9.2(@types/node@25.0.9)(typescript@5.9.3)
tsx:
specifier: ^4.21.0
version: 4.21.0
@@ -237,6 +249,58 @@ importers:
specifier: ~5.9.2
version: 5.9.3
+ remote-ui:
+ dependencies:
+ '@dnd-kit/core':
+ specifier: ^6.3.1
+ version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@dnd-kit/sortable':
+ specifier: ^10.0.0
+ version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)
+ '@dnd-kit/utilities':
+ specifier: ^3.2.2
+ version: 3.2.2(react@19.1.0)
+ '@headlessui/react':
+ specifier: ^2.2.0
+ version: 2.2.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@tanstack/react-query':
+ specifier: ^5.90.12
+ version: 5.90.12(react@19.1.0)
+ lucide-react:
+ specifier: ^0.474.0
+ version: 0.474.0(react@19.1.0)
+ react:
+ specifier: ^19.0.0
+ version: 19.1.0
+ react-dom:
+ specifier: ^19.0.0
+ version: 19.1.0(react@19.1.0)
+ tailwindcss:
+ specifier: ^4.0.0
+ version: 4.2.4
+ devDependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.0.0
+ version: 4.2.4(vite@6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+ '@types/react':
+ specifier: ^19.0.0
+ version: 19.1.17
+ '@types/react-dom':
+ specifier: ^19.0.0
+ version: 19.2.3(@types/react@19.1.17)
+ '@vitejs/plugin-react':
+ specifier: ^4.3.4
+ version: 4.7.0(vite@6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+ typescript:
+ specifier: ^5.7.3
+ version: 5.9.3
+ vite:
+ specifier: ^6.1.0
+ version: 6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+ vite-plugin-singlefile:
+ specifier: ^2.0.2
+ version: 2.3.3(rollup@4.60.2)(vite@6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+
packages:
'@0no-co/graphql.web@1.2.0':
@@ -789,12 +853,38 @@ packages:
'@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
+ '@cspotcode/source-map-support@0.8.1':
+ resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
+ engines: {node: '>=12'}
+
'@d11/react-native-fast-image@8.13.0':
resolution: {integrity: sha512-zfsBtYNttiZVV/NwEnN/PzgW3PGlGYqn0/6DUOQ/tCv1lO0gO7+S0GiANmNDl35oVmh8o0DK81lF8xAhYz/aNA==}
peerDependencies:
react: '*'
react-native: '*'
+ '@dnd-kit/accessibility@3.1.1':
+ resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ '@dnd-kit/core@6.3.1':
+ resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@dnd-kit/sortable@10.0.0':
+ resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.3.0
+ react: '>=16.8.0'
+
+ '@dnd-kit/utilities@3.2.2':
+ resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
+ peerDependencies:
+ react: '>=16.8.0'
+
'@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
@@ -1429,6 +1519,34 @@ packages:
resolution: {integrity: sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw==}
hasBin: true
+ '@floating-ui/core@1.7.5':
+ resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
+
+ '@floating-ui/dom@1.7.6':
+ resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
+
+ '@floating-ui/react-dom@2.1.8':
+ resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@floating-ui/react@0.26.28':
+ resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@floating-ui/utils@0.2.11':
+ resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
+
+ '@headlessui/react@2.2.10':
+ resolution: {integrity: sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ react: ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^18 || ^19 || ^19.0.0-rc
+
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -1445,6 +1563,15 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
+ '@internationalized/date@3.12.1':
+ resolution: {integrity: sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==}
+
+ '@internationalized/number@3.6.6':
+ resolution: {integrity: sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==}
+
+ '@internationalized/string@3.2.8':
+ resolution: {integrity: sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==}
+
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
@@ -1570,6 +1697,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@jridgewell/trace-mapping@0.3.9':
+ resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
+
'@legendapp/list@3.0.0-beta.44':
resolution: {integrity: sha512-loGRve78NuZ5k8Z54ZSDNOtv3dVBM1SeBCRtm1EYtZiDIZ8SyMVcYpUGgFpGuNKk71+9/NuM9hvScrgf7+4E+A==}
peerDependencies:
@@ -1835,6 +1965,18 @@ packages:
'@types/react':
optional: true
+ '@react-aria/focus@3.22.0':
+ resolution: {integrity: sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
+ react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
+
+ '@react-aria/interactions@3.28.0':
+ resolution: {integrity: sha512-OXwdU1EWFdMxmr/K1CXNGJzmNlCClByb+PuCaqUyzBymHPCGVhawirLIon/CrIN5psh3AiWpHSh4H0WeJdVpng==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
+ react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
+
'@react-native-async-storage/async-storage@2.2.0':
resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==}
peerDependencies:
@@ -1987,6 +2129,139 @@ packages:
'@react-navigation/routers@7.5.2':
resolution: {integrity: sha512-kymreY5aeTz843E+iPAukrsOtc7nabAH6novtAPREmmGu77dQpfxPB2ZWpKb5nRErIRowp1kYRoN2Ckl+S6JYw==}
+ '@react-types/shared@3.34.0':
+ resolution: {integrity: sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
+
+ '@rolldown/pluginutils@1.0.0-beta.27':
+ resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
+
+ '@rollup/rollup-android-arm-eabi@4.60.2':
+ resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.60.2':
+ resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.60.2':
+ resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.60.2':
+ resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.60.2':
+ resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.60.2':
+ resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.2':
+ resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.2':
+ resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.2':
+ resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.60.2':
+ resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.2':
+ resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-musl@4.60.2':
+ resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.2':
+ resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.2':
+ resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.2':
+ resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.2':
+ resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.2':
+ resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.60.2':
+ resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.60.2':
+ resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-openbsd-x64@4.60.2':
+ resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.60.2':
+ resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.2':
+ resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.2':
+ resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.60.2':
+ resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.60.2':
+ resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==}
+ cpu: [x64]
+ os: [win32]
+
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@@ -2161,6 +2436,99 @@ packages:
'@sinonjs/fake-timers@10.3.0':
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
+ '@swc/helpers@0.5.21':
+ resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
+
+ '@tailwindcss/node@4.2.4':
+ resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==}
+
+ '@tailwindcss/oxide-android-arm64@4.2.4':
+ resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.2.4':
+ resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.2.4':
+ resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.2.4':
+ resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
+ resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==}
+ engines: {node: '>= 20'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
+ resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.4':
+ resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.4':
+ resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.2.4':
+ resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.2.4':
+ resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
+ resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.4':
+ resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.2.4':
+ resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==}
+ engines: {node: '>= 20'}
+
+ '@tailwindcss/vite@4.2.4':
+ resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==}
+ peerDependencies:
+ vite: ^5.2.0 || ^6 || ^7 || ^8
+
'@tanstack/query-core@5.90.12':
resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==}
@@ -2169,6 +2537,15 @@ packages:
peerDependencies:
react: ^18 || ^19
+ '@tanstack/react-virtual@3.13.24':
+ resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@tanstack/virtual-core@3.14.0':
+ resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
+
'@testing-library/react-native@13.3.3':
resolution: {integrity: sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==}
engines: {node: '>=18'}
@@ -2185,6 +2562,18 @@ packages:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
+ '@tsconfig/node10@1.0.12':
+ resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==}
+
+ '@tsconfig/node12@1.0.11':
+ resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==}
+
+ '@tsconfig/node14@1.0.3':
+ resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==}
+
+ '@tsconfig/node16@1.0.4':
+ resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
+
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -2233,6 +2622,14 @@ packages:
'@types/node@25.0.9':
resolution: {integrity: sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==}
+ '@types/parse-json@4.0.2':
+ resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
+
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
'@types/react@19.1.17':
resolution: {integrity: sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==}
@@ -2416,6 +2813,12 @@ packages:
peerDependencies:
'@urql/core': ^5.0.0
+ '@vitejs/plugin-react@4.7.0':
+ resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
'@xmldom/xmldom@0.8.11':
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'}
@@ -2587,6 +2990,10 @@ packages:
resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ babel-plugin-macros@3.1.0:
+ resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
+ engines: {node: '>=10', npm: '>=6'}
+
babel-plugin-polyfill-corejs2@0.4.14:
resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==}
peerDependencies:
@@ -2801,6 +3208,9 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
+ cliui@6.0.0:
+ resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -2809,6 +3219,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
co@4.6.0:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
@@ -2876,6 +3290,10 @@ packages:
core-js-compat@3.47.0:
resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==}
+ cosmiconfig@7.1.0:
+ resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
+ engines: {node: '>=10'}
+
cosmiconfig@9.0.0:
resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==}
engines: {node: '>=14'}
@@ -2890,6 +3308,9 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
hasBin: true
+ create-require@1.1.1:
+ resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
+
cross-fetch@3.2.0:
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
@@ -2969,6 +3390,10 @@ packages:
supports-color:
optional: true
+ decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+ engines: {node: '>=0.10.0'}
+
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -3041,6 +3466,13 @@ packages:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ diff@4.0.4:
+ resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
+ engines: {node: '>=0.3.1'}
+
+ dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -3195,6 +3627,10 @@ packages:
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+ enhanced-resolve@5.21.0:
+ resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
+ engines: {node: '>=10.13.0'}
+
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@@ -3430,6 +3866,9 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
+ eventemitter3@5.0.4:
+ resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+
exec-async@2.2.0:
resolution: {integrity: sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==}
@@ -3576,6 +4015,12 @@ packages:
react: '*'
react-native: '*'
+ expo-network@8.0.8:
+ resolution: {integrity: sha512-dgrL8UHAmWofqeY4UEjWskCl/RoQAM0DG6PZR8xz2WZt+6aQEboQgFRXowCfhbKZ71d16sNuKXtwBEsp2DtdNw==}
+ peerDependencies:
+ expo: '*'
+ react: '*'
+
expo-router@6.0.23:
resolution: {integrity: sha512-qCxVAiCrCyu0npky6azEZ6dJDMt77OmCzEbpF6RbUTlfkaCA417LvY14SBkk0xyGruSxy/7pvJOI6tuThaUVCA==}
peerDependencies:
@@ -3993,8 +4438,8 @@ packages:
hyphenate-style-name@1.1.0:
resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==}
- i18next@26.0.6:
- resolution: {integrity: sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==}
+ i18next@26.0.8:
+ resolution: {integrity: sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==}
peerDependencies:
typescript: ^5 || ^6
peerDependenciesMeta:
@@ -4398,8 +4843,8 @@ packages:
jimp-compact@0.16.1:
resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==}
- jiti@1.21.7:
- resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
+ jiti@2.6.1:
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
js-tokens@4.0.0:
@@ -4480,74 +4925,74 @@ packages:
lighthouse-logger@1.4.2:
resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==}
- lightningcss-android-arm64@1.30.2:
- resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
- lightningcss-darwin-arm64@1.30.2:
- resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
- lightningcss-darwin-x64@1.30.2:
- resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
- lightningcss-freebsd-x64@1.30.2:
- resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
- lightningcss-linux-arm-gnueabihf@1.30.2:
- resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
- lightningcss-linux-arm64-gnu@1.30.2:
- resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- lightningcss-linux-arm64-musl@1.30.2:
- resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- lightningcss-linux-x64-gnu@1.30.2:
- resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- lightningcss-linux-x64-musl@1.30.2:
- resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- lightningcss-win32-arm64-msvc@1.30.2:
- resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
- lightningcss-win32-x64-msvc@1.30.2:
- resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
- lightningcss@1.30.2:
- resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
lines-and-columns@1.2.4:
@@ -4595,10 +5040,21 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ lucide-react@0.474.0:
+ resolution: {integrity: sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
+ make-error@1.3.6:
+ resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
+
makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
@@ -4988,6 +5444,10 @@ packages:
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
engines: {node: 20 || >=22}
+ path-type@4.0.0:
+ resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
+ engines: {node: '>=8'}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -5015,6 +5475,10 @@ packages:
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
engines: {node: '>=4.0.0'}
+ pngjs@5.0.0:
+ resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+ engines: {node: '>=10.13.0'}
+
popmotion@11.0.3:
resolution: {integrity: sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA==}
@@ -5029,6 +5493,10 @@ packages:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14}
+ postcss@8.5.12:
+ resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
+ engines: {node: ^10 || ^12 || >=14}
+
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
@@ -5097,6 +5565,11 @@ packages:
resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==}
hasBin: true
+ qrcode@1.5.4:
+ resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+
query-string@7.1.3:
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
engines: {node: '>=6'}
@@ -5115,6 +5588,12 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
+ react-aria@3.48.0:
+ resolution: {integrity: sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
+ react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
+
react-devtools-core@6.1.5:
resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==}
@@ -5132,8 +5611,8 @@ packages:
peerDependencies:
react: '>=17.0.0'
- react-i18next@17.0.4:
- resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==}
+ react-i18next@17.0.6:
+ resolution: {integrity: sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==}
peerDependencies:
i18next: '>= 26.0.1'
react: '>= 16.8.0'
@@ -5185,6 +5664,31 @@ packages:
react: '*'
react-native: '*'
+ react-native-nitro-buffer@0.2.2:
+ resolution: {integrity: sha512-mE8jD03ol2u7mAa4qcD9mRy1G6jcweI5B0JQbqcCl8U+FvTdbMW9s7tNlg1AWZkxdJLm4rBX9zl8yTlXdSNMbA==}
+ peerDependencies:
+ react: '*'
+ react-native: '*'
+ react-native-nitro-modules: ^0.35.0
+
+ react-native-nitro-http-server@1.8.1:
+ resolution: {integrity: sha512-IFkQsnUD9jdW434yb6X6H6qM4vJnapPs/gC590yk082sJj0YljXixBCnoiqhbt5D6DgV0dlZ3YVPi6Vjw58Nfg==}
+ peerDependencies:
+ react-native-nitro-modules: ^0.35.0
+
+ react-native-nitro-modules@0.35.5:
+ resolution: {integrity: sha512-aa03UzC5dLg5qFfyBkVK+JGSwHTjmK7jUZzyRz11r1Yk9C/nJTFe59EeHPxxNNTagkiwQTM6p3sySgD/TDRC7Q==}
+ peerDependencies:
+ react: '*'
+ react-native: '*'
+
+ react-native-qrcode-svg@6.3.21:
+ resolution: {integrity: sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA==}
+ peerDependencies:
+ react: '*'
+ react-native: '>=0.63.4'
+ react-native-svg: '>=14.0.0'
+
react-native-reanimated@4.1.6:
resolution: {integrity: sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==}
peerDependencies:
@@ -5246,6 +5750,10 @@ packages:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'}
+ react-refresh@0.17.0:
+ resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
+ engines: {node: '>=0.10.0'}
+
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@@ -5266,6 +5774,11 @@ packages:
'@types/react':
optional: true
+ react-stately@3.46.0:
+ resolution: {integrity: sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
+
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -5330,6 +5843,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ require-main-filename@2.0.0:
+ resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
requireg@0.2.2:
resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==}
engines: {node: '>= 4.0.0'}
@@ -5384,6 +5900,11 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
+ rollup@4.60.2:
+ resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
rtl-detect@1.1.2:
resolution: {integrity: sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==}
@@ -5453,6 +5974,9 @@ packages:
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
+ set-blocking@2.0.0:
+ resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -5715,6 +6239,16 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+ tabbable@6.4.0:
+ resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
+
+ tailwindcss@4.2.4:
+ resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==}
+
+ tapable@2.3.3:
+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
+ engines: {node: '>=6'}
+
tar-fs@2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
@@ -5744,6 +6278,10 @@ packages:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
engines: {node: '>=8'}
+ text-encoding@0.7.0:
+ resolution: {integrity: sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==}
+ deprecated: no longer maintained
+
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@@ -5789,6 +6327,20 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
+ ts-node@10.9.2:
+ resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==}
+ hasBin: true
+ peerDependencies:
+ '@swc/core': '>=1.2.50'
+ '@swc/wasm': '>=1.2.50'
+ '@types/node': '*'
+ typescript: '>=2.7'
+ peerDependenciesMeta:
+ '@swc/core':
+ optional: true
+ '@swc/wasm':
+ optional: true
+
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
@@ -5947,6 +6499,9 @@ packages:
resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==}
hasBin: true
+ v8-compile-cache-lib@3.0.1:
+ resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
+
v8-to-istanbul@9.3.0:
resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
engines: {node: '>=10.12.0'}
@@ -5965,16 +6520,66 @@ packages:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
- vlq@1.0.1:
- resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==}
-
- void-elements@3.1.0:
- resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
- engines: {node: '>=0.10.0'}
-
- w3c-xmlserializer@4.0.0:
- resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==}
- engines: {node: '>=14'}
+ vite-plugin-singlefile@2.3.3:
+ resolution: {integrity: sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==}
+ engines: {node: '>18.0.0'}
+ peerDependencies:
+ rollup: ^4.59.0
+ vite: ^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
+ vite@6.4.2:
+ resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vlq@1.0.1:
+ resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==}
+
+ void-elements@3.1.0:
+ resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
+ engines: {node: '>=0.10.0'}
+
+ w3c-xmlserializer@4.0.0:
+ resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==}
+ engines: {node: '>=14'}
walker@1.0.8:
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
@@ -6031,6 +6636,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
+ which-module@2.0.1:
+ resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
which-typed-array@1.1.19:
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
engines: {node: '>= 0.4'}
@@ -6047,6 +6655,10 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ wrap-ansi@6.2.0:
+ resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+ engines: {node: '>=8'}
+
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -6116,6 +6728,9 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+ y18n@4.0.3:
+ resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -6127,19 +6742,35 @@ packages:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
+ yaml@1.10.3:
+ resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
+ engines: {node: '>= 6'}
+
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
engines: {node: '>= 14.6'}
hasBin: true
+ yargs-parser@18.1.3:
+ resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+ engines: {node: '>=6'}
+
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
+ yargs@15.4.1:
+ resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+ engines: {node: '>=8'}
+
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
+ yn@3.1.1:
+ resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
+ engines: {node: '>=6'}
+
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -6842,11 +7473,40 @@ snapshots:
'@bcoe/v8-coverage@0.2.3': {}
+ '@cspotcode/source-map-support@0.8.1':
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.9
+
'@d11/react-native-fast-image@8.13.0(react-native-tvos@0.81.5-1)(react@19.1.0)':
dependencies:
react: 19.1.0
react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0)
+ '@dnd-kit/accessibility@3.1.1(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+ tslib: 2.8.1
+
+ '@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@dnd-kit/accessibility': 3.1.1(react@19.1.0)
+ '@dnd-kit/utilities': 3.2.2(react@19.1.0)
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ tslib: 2.8.1
+
+ '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@dnd-kit/utilities': 3.2.2(react@19.1.0)
+ react: 19.1.0
+ tslib: 2.8.1
+
+ '@dnd-kit/utilities@3.2.2(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+ tslib: 2.8.1
+
'@drizzle-team/brocli@0.10.2': {}
'@egjs/hammerjs@2.0.17':
@@ -7109,9 +7769,9 @@ snapshots:
'@esbuild/win32-x64@0.27.2':
optional: true
- '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@1.21.7))':
+ '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))':
dependencies:
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
@@ -7226,7 +7886,7 @@ snapshots:
wrap-ansi: 7.0.0
ws: 8.18.3
optionalDependencies:
- expo-router: 6.0.23(9762b74b39ac1ba59cbc6550e182d2ca)
+ expo-router: 6.0.23(d3775fd364ea69d962a61ae45d7a67fd)
react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0)
transitivePeerDependencies:
- bufferutil
@@ -7354,7 +8014,7 @@ snapshots:
glob: 13.0.0
hermes-parser: 0.29.1
jsc-safe-url: 0.2.4
- lightningcss: 1.30.2
+ lightningcss: 1.32.0
picomatch: 4.0.3
postcss: 8.4.49
resolve-from: 5.0.0
@@ -7459,6 +8119,41 @@ snapshots:
find-up: 5.0.0
js-yaml: 4.1.1
+ '@floating-ui/core@1.7.5':
+ dependencies:
+ '@floating-ui/utils': 0.2.11
+
+ '@floating-ui/dom@1.7.6':
+ dependencies:
+ '@floating-ui/core': 1.7.5
+ '@floating-ui/utils': 0.2.11
+
+ '@floating-ui/react-dom@2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@floating-ui/dom': 1.7.6
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+
+ '@floating-ui/react@0.26.28(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@floating-ui/utils': 0.2.11
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ tabbable: 6.4.0
+
+ '@floating-ui/utils@0.2.11': {}
+
+ '@headlessui/react@2.2.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@floating-ui/react': 0.26.28(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@react-aria/focus': 3.22.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@react-aria/interactions': 3.28.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@tanstack/react-virtual': 3.13.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ use-sync-external-store: 1.6.0(react@19.1.0)
+
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -7470,6 +8165,18 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
+ '@internationalized/date@3.12.1':
+ dependencies:
+ '@swc/helpers': 0.5.21
+
+ '@internationalized/number@3.6.6':
+ dependencies:
+ '@swc/helpers': 0.5.21
+
+ '@internationalized/string@3.2.8':
+ dependencies:
+ '@swc/helpers': 0.5.21
+
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0':
@@ -7501,7 +8208,7 @@ snapshots:
jest-util: 29.7.0
slash: 3.0.0
- '@jest/core@29.7.0':
+ '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
@@ -7515,7 +8222,7 @@ snapshots:
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
- jest-config: 29.7.0(@types/node@25.0.9)
+ jest-config: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@@ -7690,6 +8397,11 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping@0.3.9':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
'@legendapp/list@3.0.0-beta.44(patch_hash=ea90e6420e375cb0e56d1b7d7674e3703fd0779550fab949c43a9f571800674f)(react-dom@19.1.0(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)':
dependencies:
react: 19.1.0
@@ -7744,16 +8456,17 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
- '@radix-ui/react-collection@1.1.7(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0)
- '@radix-ui/react-primitive': 2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.17
+ '@types/react-dom': 19.2.3(@types/react@19.1.17)
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.17)(react@19.1.0)':
dependencies:
@@ -7767,18 +8480,18 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.17
- '@radix-ui/react-dialog@1.1.15(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.17)(react@19.1.0)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0)
- '@radix-ui/react-portal': 1.1.9(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
- '@radix-ui/react-presence': 1.1.5(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
- '@radix-ui/react-primitive': 2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0)
aria-hidden: 1.2.6
@@ -7787,6 +8500,7 @@ snapshots:
react-remove-scroll: 2.7.2(@types/react@19.1.17)(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.17
+ '@types/react-dom': 19.2.3(@types/react@19.1.17)
'@radix-ui/react-direction@1.1.1(@types/react@19.1.17)(react@19.1.0)':
dependencies:
@@ -7794,17 +8508,18 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.17
- '@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0)
- '@radix-ui/react-primitive': 2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0)
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.17)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.17
+ '@types/react-dom': 19.2.3(@types/react@19.1.17)
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.17)(react@19.1.0)':
dependencies:
@@ -7812,15 +8527,16 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.17
- '@radix-ui/react-focus-scope@1.1.7(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0)
- '@radix-ui/react-primitive': 2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.17
+ '@types/react-dom': 19.2.3(@types/react@19.1.17)
'@radix-ui/react-id@1.1.1(@types/react@19.1.17)(react@19.1.0)':
dependencies:
@@ -7829,16 +8545,17 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.17
- '@radix-ui/react-portal@1.1.9(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.17
+ '@types/react-dom': 19.2.3(@types/react@19.1.17)
- '@radix-ui/react-presence@1.1.5(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0)
@@ -7846,30 +8563,33 @@ snapshots:
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.17
+ '@types/react-dom': 19.2.3(@types/react@19.1.17)
- '@radix-ui/react-primitive@2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.17
+ '@types/react-dom': 19.2.3(@types/react@19.1.17)
- '@radix-ui/react-roving-focus@1.1.11(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0)
- '@radix-ui/react-primitive': 2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.17
+ '@types/react-dom': 19.2.3(@types/react@19.1.17)
'@radix-ui/react-slot@1.2.0(@types/react@19.1.17)(react@19.1.0)':
dependencies:
@@ -7885,20 +8605,21 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.17
- '@radix-ui/react-tabs@1.1.13(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0)
- '@radix-ui/react-presence': 1.1.5(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
- '@radix-ui/react-primitive': 2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.17
+ '@types/react-dom': 19.2.3(@types/react@19.1.17)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.17)(react@19.1.0)':
dependencies:
@@ -7934,6 +8655,21 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.17
+ '@react-aria/focus@3.22.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@swc/helpers': 0.5.21
+ react: 19.1.0
+ react-aria: 3.48.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ react-dom: 19.1.0(react@19.1.0)
+
+ '@react-aria/interactions@3.28.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@react-types/shared': 3.34.0(react@19.1.0)
+ '@swc/helpers': 0.5.21
+ react: 19.1.0
+ react-aria: 3.48.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ react-dom: 19.1.0(react@19.1.0)
+
'@react-native-async-storage/async-storage@2.2.0(react-native-tvos@0.81.5-1)':
dependencies:
merge-options: 3.0.4
@@ -8162,6 +8898,87 @@ snapshots:
dependencies:
nanoid: 3.3.11
+ '@react-types/shared@3.34.0(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@rolldown/pluginutils@1.0.0-beta.27': {}
+
+ '@rollup/rollup-android-arm-eabi@4.60.2':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.60.2':
+ optional: true
+
'@rtsao/scc@1.1.0': {}
'@sentry-internal/browser-utils@10.12.0':
@@ -8322,6 +9139,78 @@ snapshots:
dependencies:
'@sinonjs/commons': 3.0.1
+ '@swc/helpers@0.5.21':
+ dependencies:
+ tslib: 2.8.1
+
+ '@tailwindcss/node@4.2.4':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.21.0
+ jiti: 2.6.1
+ lightningcss: 1.32.0
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.2.4
+
+ '@tailwindcss/oxide-android-arm64@4.2.4':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.2.4':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.2.4':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.2.4':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.4':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.4':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.2.4':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.2.4':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.4':
+ optional: true
+
+ '@tailwindcss/oxide@4.2.4':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.2.4
+ '@tailwindcss/oxide-darwin-arm64': 4.2.4
+ '@tailwindcss/oxide-darwin-x64': 4.2.4
+ '@tailwindcss/oxide-freebsd-x64': 4.2.4
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4
+ '@tailwindcss/oxide-linux-arm64-musl': 4.2.4
+ '@tailwindcss/oxide-linux-x64-gnu': 4.2.4
+ '@tailwindcss/oxide-linux-x64-musl': 4.2.4
+ '@tailwindcss/oxide-wasm32-wasi': 4.2.4
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4
+ '@tailwindcss/oxide-win32-x64-msvc': 4.2.4
+
+ '@tailwindcss/vite@4.2.4(vite@6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+ dependencies:
+ '@tailwindcss/node': 4.2.4
+ '@tailwindcss/oxide': 4.2.4
+ tailwindcss: 4.2.4
+ vite: 6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+
'@tanstack/query-core@5.90.12': {}
'@tanstack/react-query@5.90.12(react@19.1.0)':
@@ -8329,7 +9218,15 @@ snapshots:
'@tanstack/query-core': 5.90.12
react: 19.1.0
- '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.0.9))(react-native-tvos@0.81.5-1)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
+ '@tanstack/react-virtual@3.13.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@tanstack/virtual-core': 3.14.0
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+
+ '@tanstack/virtual-core@3.14.0': {}
+
+ '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)))(react-native-tvos@0.81.5-1)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
jest-matcher-utils: 30.2.0
picocolors: 1.1.1
@@ -8339,10 +9236,18 @@ snapshots:
react-test-renderer: 19.1.0(react@19.1.0)
redent: 3.0.0
optionalDependencies:
- jest: 29.7.0(@types/node@25.0.9)
+ jest: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))
'@tootallnate/once@2.0.0': {}
+ '@tsconfig/node10@1.0.12': {}
+
+ '@tsconfig/node12@1.0.11': {}
+
+ '@tsconfig/node14@1.0.3': {}
+
+ '@tsconfig/node16@1.0.4': {}
+
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@@ -8350,8 +9255,8 @@ snapshots:
'@types/babel__core@7.20.5':
dependencies:
- '@babel/parser': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/parser': 7.28.6
+ '@babel/types': 7.28.6
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
@@ -8406,6 +9311,13 @@ snapshots:
dependencies:
undici-types: 7.16.0
+ '@types/parse-json@4.0.2':
+ optional: true
+
+ '@types/react-dom@19.2.3(@types/react@19.1.17)':
+ dependencies:
+ '@types/react': 19.1.17
+
'@types/react@19.1.17':
dependencies:
csstype: 3.2.3
@@ -8422,15 +9334,15 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
- '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.49.0
- '@typescript-eslint/type-utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/type-utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.49.0
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.1.0(typescript@5.9.3)
@@ -8438,14 +9350,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.49.0
'@typescript-eslint/types': 8.49.0
'@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.49.0
debug: 4.4.3
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -8468,13 +9380,13 @@ snapshots:
dependencies:
typescript: 5.9.3
- '@typescript-eslint/type-utils@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)':
+ '@typescript-eslint/type-utils@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.49.0
'@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
debug: 4.4.3
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -8497,13 +9409,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)':
+ '@typescript-eslint/utils@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.49.0
'@typescript-eslint/types': 8.49.0
'@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3)
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -8586,6 +9498,18 @@ snapshots:
'@urql/core': 5.2.0
wonka: 6.3.5
+ '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5)
+ '@rolldown/pluginutils': 1.0.0-beta.27
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.17.0
+ vite: 6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+ transitivePeerDependencies:
+ - supports-color
+
'@xmldom/xmldom@0.8.11': {}
abab@2.0.6: {}
@@ -8792,6 +9716,13 @@ snapshots:
'@types/babel__core': 7.20.5
'@types/babel__traverse': 7.28.0
+ babel-plugin-macros@3.1.0:
+ dependencies:
+ '@babel/runtime': 7.29.2
+ cosmiconfig: 7.1.0
+ resolve: 1.22.11
+ optional: true
+
babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.28.5):
dependencies:
'@babel/compat-data': 7.28.5
@@ -8853,7 +9784,7 @@ snapshots:
'@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5)
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5)
- babel-preset-expo@54.0.10(@babel/core@7.28.5)(@babel/runtime@7.29.2)(expo@54.0.34)(react-refresh@0.14.2):
+ babel-preset-expo@54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.34)(react-refresh@0.14.2):
dependencies:
'@babel/helper-module-imports': 7.27.1
'@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.5)
@@ -8879,7 +9810,39 @@ snapshots:
react-refresh: 0.14.2
resolve-from: 5.0.0
optionalDependencies:
- '@babel/runtime': 7.29.2
+ '@babel/runtime': 7.28.4
+ expo: 54.0.34(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-tvos@0.81.5-1)(react@19.1.0)
+ transitivePeerDependencies:
+ - '@babel/core'
+ - supports-color
+
+ babel-preset-expo@54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.34)(react-refresh@0.17.0):
+ dependencies:
+ '@babel/helper-module-imports': 7.27.1
+ '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.5)
+ '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-syntax-export-default-from': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-class-static-block': 7.28.3(@babel/core@7.28.5)
+ '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-object-rest-spread': 7.28.4(@babel/core@7.28.5)
+ '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.5)
+ '@babel/plugin-transform-private-methods': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-private-property-in-object': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-runtime': 7.28.5(@babel/core@7.28.5)
+ '@babel/preset-react': 7.28.5(@babel/core@7.28.5)
+ '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5)
+ '@react-native/babel-preset': 0.81.5(@babel/core@7.28.5)
+ babel-plugin-react-compiler: 1.0.0
+ babel-plugin-react-native-web: 0.21.2
+ babel-plugin-syntax-hermes-parser: 0.29.1
+ babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.28.5)
+ debug: 4.4.3
+ react-refresh: 0.17.0
+ resolve-from: 5.0.0
+ optionalDependencies:
+ '@babel/runtime': 7.28.4
expo: 54.0.34(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-tvos@0.81.5-1)(react@19.1.0)
transitivePeerDependencies:
- '@babel/core'
@@ -9055,6 +10018,12 @@ snapshots:
client-only@0.0.1: {}
+ cliui@6.0.0:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 6.2.0
+
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -9063,6 +10032,8 @@ snapshots:
clone@1.0.4: {}
+ clsx@2.1.1: {}
+
co@4.6.0: {}
collect-v8-coverage@1.0.3: {}
@@ -9134,6 +10105,15 @@ snapshots:
dependencies:
browserslist: 4.28.1
+ cosmiconfig@7.1.0:
+ dependencies:
+ '@types/parse-json': 4.0.2
+ import-fresh: 3.3.1
+ parse-json: 5.2.0
+ path-type: 4.0.0
+ yaml: 1.10.3
+ optional: true
+
cosmiconfig@9.0.0(typescript@5.9.3):
dependencies:
env-paths: 2.2.1
@@ -9143,13 +10123,13 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
- create-jest@29.7.0(@types/node@25.0.9):
+ create-jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
- jest-config: 29.7.0(@types/node@25.0.9)
+ jest-config: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
@@ -9158,6 +10138,8 @@ snapshots:
- supports-color
- ts-node
+ create-require@1.1.1: {}
+
cross-fetch@3.2.0:
dependencies:
node-fetch: 2.7.0
@@ -9237,6 +10219,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decamelize@1.2.0: {}
+
decimal.js@10.6.0: {}
decode-uri-component@0.2.2: {}
@@ -9245,7 +10229,9 @@ snapshots:
dependencies:
mimic-response: 3.1.0
- dedent@1.7.1: {}
+ dedent@1.7.1(babel-plugin-macros@3.1.0):
+ optionalDependencies:
+ babel-plugin-macros: 3.1.0
deep-extend@0.6.0: {}
@@ -9285,6 +10271,10 @@ snapshots:
diff-sequences@29.6.3: {}
+ diff@4.0.4: {}
+
+ dijkstrajs@1.0.3: {}
+
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -9353,6 +10343,11 @@ snapshots:
dependencies:
once: 1.4.0
+ enhanced-resolve@5.21.0:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.3
+
entities@4.5.0: {}
entities@6.0.1: {}
@@ -9578,16 +10573,16 @@ snapshots:
optionalDependencies:
source-map: 0.6.1
- eslint-config-expo@10.0.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3):
+ eslint-config-expo@10.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
- eslint: 9.39.1(jiti@1.21.7)
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7))
- eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7))
- eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7))
- eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@1.21.7))
+ '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ eslint: 9.39.1(jiti@2.6.1)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
+ eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1))
+ eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1))
+ eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1))
globals: 16.5.0
transitivePeerDependencies:
- eslint-import-resolver-webpack
@@ -9595,9 +10590,9 @@ snapshots:
- supports-color
- typescript
- eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@1.21.7)):
+ eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)):
dependencies:
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-node@0.3.9:
dependencies:
@@ -9607,42 +10602,42 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
get-tsconfig: 4.13.0
is-bun-module: 2.0.0
stable-hash: 0.0.5
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
- '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
- eslint: 9.39.1(jiti@1.21.7)
+ '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-plugin-expo@1.0.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3):
+ eslint-plugin-expo@1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@typescript-eslint/types': 8.49.0
- '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
- eslint: 9.39.1(jiti@1.21.7)
+ '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ eslint: 9.39.1(jiti@2.6.1)
transitivePeerDependencies:
- supports-color
- typescript
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -9651,9 +10646,9 @@ snapshots:
array.prototype.flatmap: 1.3.3
debug: 3.2.7
doctrine: 2.1.0
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -9665,29 +10660,29 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
- '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
- eslint-plugin-react-compiler@19.1.0-rc.2(eslint@9.39.1(jiti@1.21.7)):
+ eslint-plugin-react-compiler@19.1.0-rc.2(eslint@9.39.1(jiti@2.6.1)):
dependencies:
'@babel/core': 7.28.5
'@babel/parser': 7.28.6
'@babel/plugin-proposal-private-methods': 7.18.6(@babel/core@7.28.5)
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
hermes-parser: 0.25.1
zod: 3.25.76
zod-validation-error: 3.5.4(zod@3.25.76)
transitivePeerDependencies:
- supports-color
- eslint-plugin-react-hooks@5.2.0(eslint@9.39.1(jiti@1.21.7)):
+ eslint-plugin-react-hooks@5.2.0(eslint@9.39.1(jiti@2.6.1)):
dependencies:
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
- eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@1.21.7)):
+ eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
@@ -9695,7 +10690,7 @@ snapshots:
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.2.1
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
@@ -9718,9 +10713,9 @@ snapshots:
eslint-visitor-keys@4.2.1: {}
- eslint@9.39.1(jiti@1.21.7):
+ eslint@9.39.1(jiti@2.6.1):
dependencies:
- '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.1
'@eslint/config-helpers': 0.4.2
@@ -9755,7 +10750,7 @@ snapshots:
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
- jiti: 1.21.7
+ jiti: 2.6.1
transitivePeerDependencies:
- supports-color
@@ -9783,6 +10778,8 @@ snapshots:
event-target-shim@5.0.1: {}
+ eventemitter3@5.0.4: {}
+
exec-async@2.2.0: {}
execa@5.1.1:
@@ -9958,12 +10955,17 @@ snapshots:
react: 19.1.0
react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0)
- expo-router@6.0.23(9762b74b39ac1ba59cbc6550e182d2ca):
+ expo-network@8.0.8(expo@54.0.34)(react@19.1.0):
+ dependencies:
+ expo: 54.0.34(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-tvos@0.81.5-1)(react@19.1.0)
+ react: 19.1.0
+
+ expo-router@6.0.23(d3775fd364ea69d962a61ae45d7a67fd):
dependencies:
'@expo/metro-runtime': 6.1.2(expo@54.0.34)(react-dom@19.1.0(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)
'@expo/schema-utils': 0.1.8
'@radix-ui/react-slot': 1.2.0(@types/react@19.1.17)(react@19.1.0)
- '@radix-ui/react-tabs': 1.1.13(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@react-navigation/bottom-tabs': 7.8.12(@react-navigation/native@7.1.25(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-screens@4.16.0(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)
'@react-navigation/native': 7.1.25(react-native-tvos@0.81.5-1)(react@19.1.0)
'@react-navigation/native-stack': 7.8.6(@react-navigation/native@7.1.25(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-screens@4.16.0(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)
@@ -9989,10 +10991,10 @@ snapshots:
sf-symbols-typescript: 2.2.0
shallowequal: 1.1.0
use-latest-callback: 0.2.6(react@19.1.0)
- vaul: 1.1.2(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
optionalDependencies:
'@react-navigation/drawer': 7.7.9(@react-navigation/native@7.1.25(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-reanimated@4.1.6(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-screens@4.16.0(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)
- '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.0.9))(react-native-tvos@0.81.5-1)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
+ '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)))(react-native-tvos@0.81.5-1)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
react-dom: 19.1.0(react@19.1.0)
react-native-gesture-handler: 2.28.0(react-native-tvos@0.81.5-1)(react@19.1.0)
react-native-reanimated: 4.1.6(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react@19.1.0))(react@19.1.0)
@@ -10014,12 +11016,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
- expo-sqlite-mock@3.0.2(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0))(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9)):
+ expo-sqlite-mock@3.0.2(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0))(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))):
dependencies:
better-sqlite3: 12.6.2
expo: 54.0.34(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-tvos@0.81.5-1)(react@19.1.0)
expo-sqlite: 16.0.10(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0)
- jest: 29.7.0(@types/node@25.0.9)
+ jest: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))
expo-sqlite@16.0.10(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0):
dependencies:
@@ -10080,7 +11082,7 @@ snapshots:
expo@54.0.34(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-tvos@0.81.5-1)(react@19.1.0):
dependencies:
- '@babel/runtime': 7.29.2
+ '@babel/runtime': 7.28.4
'@expo/cli': 54.0.24(expo-router@6.0.23)(expo@54.0.34)(react-native-tvos@0.81.5-1)
'@expo/config': 12.0.13
'@expo/config-plugins': 54.0.4
@@ -10090,7 +11092,7 @@ snapshots:
'@expo/metro-config': 54.0.15(expo@54.0.34)
'@expo/vector-icons': 15.0.3(expo-font@14.0.11(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)
'@ungap/structured-clone': 1.3.0
- babel-preset-expo: 54.0.10(@babel/core@7.28.5)(@babel/runtime@7.29.2)(expo@54.0.34)(react-refresh@0.14.2)
+ babel-preset-expo: 54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.34)(react-refresh@0.14.2)
expo-asset: 12.0.13(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0)
expo-constants: 18.0.13(expo@54.0.34)(react-native-tvos@0.81.5-1)
expo-file-system: 19.0.22(expo@54.0.34)(react-native-tvos@0.81.5-1)
@@ -10427,9 +11429,7 @@ snapshots:
hyphenate-style-name@1.1.0: {}
- i18next@26.0.6(typescript@5.9.3):
- dependencies:
- '@babel/runtime': 7.29.2
+ i18next@26.0.8(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
@@ -10676,7 +11676,7 @@ snapshots:
jest-util: 29.7.0
p-limit: 3.1.0
- jest-circus@29.7.0:
+ jest-circus@29.7.0(babel-plugin-macros@3.1.0):
dependencies:
'@jest/environment': 29.7.0
'@jest/expect': 29.7.0
@@ -10685,7 +11685,7 @@ snapshots:
'@types/node': 25.0.9
chalk: 4.1.2
co: 4.6.0
- dedent: 1.7.1
+ dedent: 1.7.1(babel-plugin-macros@3.1.0)
is-generator-fn: 2.1.0
jest-each: 29.7.0
jest-matcher-utils: 29.7.0
@@ -10702,16 +11702,16 @@ snapshots:
- babel-plugin-macros
- supports-color
- jest-cli@29.7.0(@types/node@25.0.9):
+ jest-cli@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)):
dependencies:
- '@jest/core': 29.7.0
+ '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
- create-jest: 29.7.0(@types/node@25.0.9)
+ create-jest: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))
exit: 0.1.2
import-local: 3.2.0
- jest-config: 29.7.0(@types/node@25.0.9)
+ jest-config: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
@@ -10721,7 +11721,7 @@ snapshots:
- supports-color
- ts-node
- jest-config@29.7.0(@types/node@25.0.9):
+ jest-config@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.28.5
'@jest/test-sequencer': 29.7.0
@@ -10732,7 +11732,7 @@ snapshots:
deepmerge: 4.3.1
glob: 7.2.3
graceful-fs: 4.2.11
- jest-circus: 29.7.0
+ jest-circus: 29.7.0(babel-plugin-macros@3.1.0)
jest-environment-node: 29.7.0
jest-get-type: 29.6.3
jest-regex-util: 29.6.3
@@ -10747,6 +11747,7 @@ snapshots:
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 25.0.9
+ ts-node: 10.9.2(@types/node@25.0.9)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -10801,7 +11802,7 @@ snapshots:
jest-mock: 29.7.0
jest-util: 29.7.0
- jest-expo@54.0.17(@babel/core@7.28.5)(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9))(react-native-tvos@0.81.5-1)(react@19.1.0):
+ jest-expo@54.0.17(@babel/core@7.28.5)(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)))(react-native-tvos@0.81.5-1)(react@19.1.0):
dependencies:
'@expo/config': 12.0.13
'@expo/json-file': 10.0.8
@@ -10812,7 +11813,7 @@ snapshots:
jest-environment-jsdom: 29.7.0
jest-snapshot: 29.7.0
jest-watch-select-projects: 2.0.0
- jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.0.9))
+ jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)))
json5: 2.2.3
lodash: 4.17.21
react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0)
@@ -11010,11 +12011,11 @@ snapshots:
chalk: 3.0.0
prompts: 2.4.2
- jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@25.0.9)):
+ jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))):
dependencies:
ansi-escapes: 6.2.1
chalk: 4.1.2
- jest: 29.7.0(@types/node@25.0.9)
+ jest: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))
jest-regex-util: 29.6.3
jest-watcher: 29.7.0
slash: 5.1.0
@@ -11039,12 +12040,12 @@ snapshots:
merge-stream: 2.0.0
supports-color: 8.1.1
- jest@29.7.0(@types/node@25.0.9):
+ jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)):
dependencies:
- '@jest/core': 29.7.0
+ '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))
'@jest/types': 29.6.3
import-local: 3.2.0
- jest-cli: 29.7.0(@types/node@25.0.9)
+ jest-cli: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -11053,8 +12054,7 @@ snapshots:
jimp-compact@0.16.1: {}
- jiti@1.21.7:
- optional: true
+ jiti@2.6.1: {}
js-tokens@4.0.0: {}
@@ -11149,54 +12149,54 @@ snapshots:
transitivePeerDependencies:
- supports-color
- lightningcss-android-arm64@1.30.2:
+ lightningcss-android-arm64@1.32.0:
optional: true
- lightningcss-darwin-arm64@1.30.2:
+ lightningcss-darwin-arm64@1.32.0:
optional: true
- lightningcss-darwin-x64@1.30.2:
+ lightningcss-darwin-x64@1.32.0:
optional: true
- lightningcss-freebsd-x64@1.30.2:
+ lightningcss-freebsd-x64@1.32.0:
optional: true
- lightningcss-linux-arm-gnueabihf@1.30.2:
+ lightningcss-linux-arm-gnueabihf@1.32.0:
optional: true
- lightningcss-linux-arm64-gnu@1.30.2:
+ lightningcss-linux-arm64-gnu@1.32.0:
optional: true
- lightningcss-linux-arm64-musl@1.30.2:
+ lightningcss-linux-arm64-musl@1.32.0:
optional: true
- lightningcss-linux-x64-gnu@1.30.2:
+ lightningcss-linux-x64-gnu@1.32.0:
optional: true
- lightningcss-linux-x64-musl@1.30.2:
+ lightningcss-linux-x64-musl@1.32.0:
optional: true
- lightningcss-win32-arm64-msvc@1.30.2:
+ lightningcss-win32-arm64-msvc@1.32.0:
optional: true
- lightningcss-win32-x64-msvc@1.30.2:
+ lightningcss-win32-x64-msvc@1.32.0:
optional: true
- lightningcss@1.30.2:
+ lightningcss@1.32.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
- lightningcss-android-arm64: 1.30.2
- lightningcss-darwin-arm64: 1.30.2
- lightningcss-darwin-x64: 1.30.2
- lightningcss-freebsd-x64: 1.30.2
- lightningcss-linux-arm-gnueabihf: 1.30.2
- lightningcss-linux-arm64-gnu: 1.30.2
- lightningcss-linux-arm64-musl: 1.30.2
- lightningcss-linux-x64-gnu: 1.30.2
- lightningcss-linux-x64-musl: 1.30.2
- lightningcss-win32-arm64-msvc: 1.30.2
- lightningcss-win32-x64-msvc: 1.30.2
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
lines-and-columns@1.2.4: {}
@@ -11236,10 +12236,20 @@ snapshots:
dependencies:
yallist: 3.1.1
+ lucide-react@0.474.0(react@19.1.0):
+ dependencies:
+ react: 19.1.0
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
make-dir@4.0.0:
dependencies:
semver: 7.7.3
+ make-error@1.3.6: {}
+
makeerror@1.0.12:
dependencies:
tmpl: 1.0.5
@@ -11718,6 +12728,9 @@ snapshots:
lru-cache: 11.2.4
minipass: 7.1.2
+ path-type@4.0.0:
+ optional: true
+
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -11738,6 +12751,8 @@ snapshots:
pngjs@3.4.0: {}
+ pngjs@5.0.0: {}
+
popmotion@11.0.3:
dependencies:
framesync: 6.0.1
@@ -11755,6 +12770,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ postcss@8.5.12:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
@@ -11828,6 +12849,12 @@ snapshots:
qrcode-terminal@0.11.0: {}
+ qrcode@1.5.4:
+ dependencies:
+ dijkstrajs: 1.0.3
+ pngjs: 5.0.0
+ yargs: 15.4.1
+
query-string@7.1.3:
dependencies:
decode-uri-component: 0.2.2
@@ -11850,6 +12877,20 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
+ react-aria@3.48.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ dependencies:
+ '@internationalized/date': 3.12.1
+ '@internationalized/number': 3.6.6
+ '@internationalized/string': 3.2.8
+ '@react-types/shared': 3.34.0(react@19.1.0)
+ '@swc/helpers': 0.5.21
+ aria-hidden: 1.2.6
+ clsx: 2.1.1
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ react-stately: 3.46.0(react@19.1.0)
+ use-sync-external-store: 1.6.0(react@19.1.0)
+
react-devtools-core@6.1.5:
dependencies:
shell-quote: 1.8.3
@@ -11869,11 +12910,11 @@ snapshots:
dependencies:
react: 19.1.0
- react-i18next@17.0.4(i18next@26.0.6(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)(typescript@5.9.3):
+ react-i18next@17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
html-parse-stringify: 3.0.1
- i18next: 26.0.6(typescript@5.9.3)
+ i18next: 26.0.8(typescript@5.9.3)
react: 19.1.0
use-sync-external-store: 1.6.0(react@19.1.0)
optionalDependencies:
@@ -11915,6 +12956,35 @@ snapshots:
react: 19.1.0
react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0)
+ react-native-nitro-buffer@0.2.2(react-native-nitro-modules@0.35.5(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0):
+ dependencies:
+ react: 19.1.0
+ react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0)
+ react-native-nitro-modules: 0.35.5(react-native-tvos@0.81.5-1)(react@19.1.0)
+
+ react-native-nitro-http-server@1.8.1(react-native-nitro-modules@0.35.5(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0):
+ dependencies:
+ eventemitter3: 5.0.4
+ react-native-nitro-buffer: 0.2.2(react-native-nitro-modules@0.35.5(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)
+ react-native-nitro-modules: 0.35.5(react-native-tvos@0.81.5-1)(react@19.1.0)
+ transitivePeerDependencies:
+ - react
+ - react-native
+
+ react-native-nitro-modules@0.35.5(react-native-tvos@0.81.5-1)(react@19.1.0):
+ dependencies:
+ react: 19.1.0
+ react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0)
+
+ react-native-qrcode-svg@6.3.21(react-native-svg@15.12.1(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0):
+ dependencies:
+ prop-types: 15.8.1
+ qrcode: 1.5.4
+ react: 19.1.0
+ react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0)
+ react-native-svg: 15.12.1(react-native-tvos@0.81.5-1)(react@19.1.0)
+ text-encoding: 0.7.0
+
react-native-reanimated@4.1.6(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/core': 7.28.5
@@ -12034,6 +13104,8 @@ snapshots:
react-refresh@0.14.2: {}
+ react-refresh@0.17.0: {}
+
react-remove-scroll-bar@2.3.8(@types/react@19.1.17)(react@19.1.0):
dependencies:
react: 19.1.0
@@ -12053,6 +13125,16 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.17
+ react-stately@3.46.0(react@19.1.0):
+ dependencies:
+ '@internationalized/date': 3.12.1
+ '@internationalized/number': 3.6.6
+ '@internationalized/string': 3.2.8
+ '@react-types/shared': 3.34.0(react@19.1.0)
+ '@swc/helpers': 0.5.21
+ react: 19.1.0
+ use-sync-external-store: 1.6.0(react@19.1.0)
+
react-style-singleton@2.2.3(@types/react@19.1.17)(react@19.1.0):
dependencies:
get-nonce: 1.0.1
@@ -12127,6 +13209,8 @@ snapshots:
require-from-string@2.0.2: {}
+ require-main-filename@2.0.0: {}
+
requireg@0.2.2:
dependencies:
nested-error-stacks: 2.0.1
@@ -12178,6 +13262,37 @@ snapshots:
dependencies:
glob: 7.2.3
+ rollup@4.60.2:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.60.2
+ '@rollup/rollup-android-arm64': 4.60.2
+ '@rollup/rollup-darwin-arm64': 4.60.2
+ '@rollup/rollup-darwin-x64': 4.60.2
+ '@rollup/rollup-freebsd-arm64': 4.60.2
+ '@rollup/rollup-freebsd-x64': 4.60.2
+ '@rollup/rollup-linux-arm-gnueabihf': 4.60.2
+ '@rollup/rollup-linux-arm-musleabihf': 4.60.2
+ '@rollup/rollup-linux-arm64-gnu': 4.60.2
+ '@rollup/rollup-linux-arm64-musl': 4.60.2
+ '@rollup/rollup-linux-loong64-gnu': 4.60.2
+ '@rollup/rollup-linux-loong64-musl': 4.60.2
+ '@rollup/rollup-linux-ppc64-gnu': 4.60.2
+ '@rollup/rollup-linux-ppc64-musl': 4.60.2
+ '@rollup/rollup-linux-riscv64-gnu': 4.60.2
+ '@rollup/rollup-linux-riscv64-musl': 4.60.2
+ '@rollup/rollup-linux-s390x-gnu': 4.60.2
+ '@rollup/rollup-linux-x64-gnu': 4.60.2
+ '@rollup/rollup-linux-x64-musl': 4.60.2
+ '@rollup/rollup-openbsd-x64': 4.60.2
+ '@rollup/rollup-openharmony-arm64': 4.60.2
+ '@rollup/rollup-win32-arm64-msvc': 4.60.2
+ '@rollup/rollup-win32-ia32-msvc': 4.60.2
+ '@rollup/rollup-win32-x64-gnu': 4.60.2
+ '@rollup/rollup-win32-x64-msvc': 4.60.2
+ fsevents: 2.3.3
+
rtl-detect@1.1.2: {}
safe-array-concat@1.1.3:
@@ -12268,6 +13383,8 @@ snapshots:
server-only@0.0.1: {}
+ set-blocking@2.0.0: {}
+
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -12554,6 +13671,12 @@ snapshots:
symbol-tree@3.2.4: {}
+ tabbable@6.4.0: {}
+
+ tailwindcss@4.2.4: {}
+
+ tapable@2.3.3: {}
+
tar-fs@2.1.4:
dependencies:
chownr: 1.1.4
@@ -12597,6 +13720,8 @@ snapshots:
glob: 7.2.3
minimatch: 3.1.2
+ text-encoding@0.7.0: {}
+
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
@@ -12639,6 +13764,24 @@ snapshots:
ts-interface-checker@0.1.13: {}
+ ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3):
+ dependencies:
+ '@cspotcode/source-map-support': 0.8.1
+ '@tsconfig/node10': 1.0.12
+ '@tsconfig/node12': 1.0.11
+ '@tsconfig/node14': 1.0.3
+ '@tsconfig/node16': 1.0.4
+ '@types/node': 25.0.9
+ acorn: 8.15.0
+ acorn-walk: 8.3.4
+ arg: 4.1.0
+ create-require: 1.1.1
+ diff: 4.0.4
+ make-error: 1.3.6
+ typescript: 5.9.3
+ v8-compile-cache-lib: 3.0.1
+ yn: 3.1.1
+
tsconfig-paths@3.15.0:
dependencies:
'@types/json5': 0.0.29
@@ -12808,6 +13951,8 @@ snapshots:
uuid@7.0.3: {}
+ v8-compile-cache-lib@3.0.1: {}
+
v8-to-istanbul@9.3.0:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -12818,15 +13963,39 @@ snapshots:
vary@1.1.2: {}
- vaul@1.1.2(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
- '@radix-ui/react-dialog': 1.1.15(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
+ vite-plugin-singlefile@2.3.3(rollup@4.60.2)(vite@6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
+ dependencies:
+ micromatch: 4.0.8
+ vite: 6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+ optionalDependencies:
+ rollup: 4.60.2
+
+ vite@6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+ postcss: 8.5.12
+ rollup: 4.60.2
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 25.0.9
+ fsevents: 2.3.3
+ jiti: 2.6.1
+ lightningcss: 1.32.0
+ terser: 5.44.1
+ tsx: 4.21.0
+ yaml: 2.8.2
+
vlq@1.0.1: {}
void-elements@3.1.0: {}
@@ -12906,6 +14075,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
+ which-module@2.0.1: {}
+
which-typed-array@1.1.19:
dependencies:
available-typed-arrays: 1.0.7
@@ -12924,6 +14095,12 @@ snapshots:
word-wrap@1.2.5: {}
+ wrap-ansi@6.2.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -12963,16 +14140,40 @@ snapshots:
xmlchars@2.2.0: {}
+ y18n@4.0.3: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}
yallist@5.0.0: {}
+ yaml@1.10.3:
+ optional: true
+
yaml@2.8.2: {}
+ yargs-parser@18.1.3:
+ dependencies:
+ camelcase: 5.3.1
+ decamelize: 1.2.0
+
yargs-parser@21.1.1: {}
+ yargs@15.4.1:
+ dependencies:
+ cliui: 6.0.0
+ decamelize: 1.2.0
+ find-up: 4.1.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ require-main-filename: 2.0.0
+ set-blocking: 2.0.0
+ string-width: 4.2.3
+ which-module: 2.0.1
+ y18n: 4.0.3
+ yargs-parser: 18.1.3
+
yargs@17.7.2:
dependencies:
cliui: 8.0.1
@@ -12983,6 +14184,8 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
+ yn@3.1.1: {}
+
yocto-queue@0.1.0: {}
yocto-queue@1.2.2: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 6de2b1b..2b612a6 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,2 +1,5 @@
+packages:
+ - 'remote-ui'
+
onlyBuiltDependencies:
- 'react-native-video'
diff --git a/remote-ui/index.html b/remote-ui/index.html
new file mode 100644
index 0000000..5252080
--- /dev/null
+++ b/remote-ui/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ DodoStream Remote
+
+
+
+
+
+
diff --git a/remote-ui/package.json b/remote-ui/package.json
new file mode 100644
index 0000000..402ae7c
--- /dev/null
+++ b/remote-ui/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "remote-ui",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@headlessui/react": "^2.2.0",
+ "@tanstack/react-query": "^5.90.12",
+ "lucide-react": "^0.474.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "tailwindcss": "^4.0.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.3.4",
+ "typescript": "^5.7.3",
+ "vite": "^6.1.0",
+ "vite-plugin-singlefile": "^2.0.2"
+ }
+}
\ No newline at end of file
diff --git a/remote-ui/src/App.tsx b/remote-ui/src/App.tsx
new file mode 100644
index 0000000..d4f0a5e
--- /dev/null
+++ b/remote-ui/src/App.tsx
@@ -0,0 +1,107 @@
+import { useEffect, useState } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { AddonsView } from './components/AddonsView';
+import { BottomNav, ViewTab } from './components/BottomNav';
+import { DisconnectionBanner } from './components/DisconnectionBanner';
+import { Header } from './components/Header';
+import { LoadingSpinner } from './components/LoadingSpinner';
+import { PinDialog } from './components/PinDialog';
+import { ProfilesView } from './components/ProfilesView';
+import { SessionEndedScreen } from './components/SessionEndedScreen';
+import { api, isSessionEndedError, Profile, setPin } from './api';
+import { queryKeys } from './queries';
+
+const HEALTH_CHECK_INTERVAL_MS = 5_000;
+
+function consumeUrlPin(): void {
+ const params = new URLSearchParams(window.location.search);
+ const pin = params.get('pin');
+ if (!pin) return;
+
+ setPin(pin);
+ params.delete('pin');
+ const query = params.toString();
+ const nextUrl = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`;
+ window.history.replaceState({}, '', nextUrl);
+}
+
+export default function App() {
+ const [hasPin, setHasPin] = useState(() => {
+ consumeUrlPin();
+ return Boolean(localStorage.getItem('dodostream_pin'));
+ });
+ const [view, setView] = useState('addons');
+ const [sessionEnded, setSessionEnded] = useState(false);
+ const queryClient = useQueryClient();
+
+ const profilesQuery = useQuery({
+ queryKey: queryKeys.profiles.all,
+ queryFn: api.getProfiles,
+ enabled: hasPin && !sessionEnded,
+ retry: false,
+ refetchInterval: (query) =>
+ query.state.status === 'success' ? HEALTH_CHECK_INTERVAL_MS : false,
+ });
+
+ // Detect session-ended errors from any fetch (initial or background refetch)
+ useEffect(() => {
+ if (profilesQuery.error && isSessionEndedError(profilesQuery.error)) {
+ setSessionEnded(true);
+ }
+ }, [profilesQuery.error]);
+
+ const handleSessionEnded = () => {
+ setSessionEnded(true);
+ };
+
+ const handlePinSuccess = () => {
+ setHasPin(true);
+ void queryClient.invalidateQueries({ queryKey: queryKeys.profiles.all });
+ };
+
+ // Derive status from query state â no manual state machine needed
+ const status = (() => {
+ if (sessionEnded) return 'session-ended' as const;
+ if (!hasPin) return 'pin-required' as const;
+ if (profilesQuery.status === 'error') {
+ return isSessionEndedError(profilesQuery.error)
+ ? ('session-ended' as const)
+ : ('pin-required' as const);
+ }
+ if (profilesQuery.status === 'success') return 'ready' as const;
+ return 'loading' as const; // pending with query enabled
+ })();
+
+ const profiles: Profile[] = profilesQuery.data ?? [];
+ const isDisconnected = status === 'ready' && profilesQuery.error !== null;
+
+ if (status === 'session-ended') {
+ return ;
+ }
+
+ return (
+
+
+
+
+ {status === 'ready' && isDisconnected && }
+
+
+ {status === 'loading' &&
}
+
+ {status === 'ready' && view === 'addons' && (
+
+ )}
+
+ {status === 'ready' && view === 'profiles' && (
+
+ )}
+
+ {status === 'pin-required' &&
}
+
+
+
+ {status === 'ready' &&
}
+
+ );
+}
\ No newline at end of file
diff --git a/remote-ui/src/api.ts b/remote-ui/src/api.ts
new file mode 100644
index 0000000..44a81ef
--- /dev/null
+++ b/remote-ui/src/api.ts
@@ -0,0 +1,84 @@
+import { type AddonConfig } from '@app/types/addon-config';
+
+// Base URL is the TV's server â same origin when served by the TV
+export const isSessionEndedError = (error: unknown): boolean => {
+ if (!(error instanceof Error)) return false;
+ if (error.message === 'UNAUTHORIZED') return true;
+ // Browser fetch throws TypeError for network failures (DNS, CORS, offline).
+ // These have well-known messages per engine: "Failed to fetch" (Chromium),
+ // "Load failed" (WebKit), "NetworkError when attempting to fetch" (Gecko).
+ if (error.name === 'TypeError') {
+ const msg = error.message;
+ return (
+ msg.includes('Failed to fetch') ||
+ msg.includes('Load failed') ||
+ msg.includes('NetworkError when attempting to fetch')
+ );
+ }
+ return false;
+};
+
+const getBaseUrl = () => {
+ return window.location.origin;
+};
+
+const getPin = () => localStorage.getItem('dodostream_pin') ?? '';
+
+export const setPin = (pin: string) => localStorage.setItem('dodostream_pin', pin);
+const clearPin = () => localStorage.removeItem('dodostream_pin');
+
+async function request(method: string, path: string, body?: unknown): Promise {
+ const res = await fetch(`${getBaseUrl()}${path}`, {
+ method,
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${getPin()}`,
+ },
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ });
+
+ if (res.status === 401) {
+ clearPin();
+ throw new Error('UNAUTHORIZED');
+ }
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || `HTTP ${res.status}`);
+ }
+
+ if (res.status === 204 || res.status === 205) {
+ return undefined as T;
+ }
+
+ return res.json() as Promise;
+}
+
+export interface Profile {
+ id: string;
+ name: string;
+ color: string;
+}
+
+export type { AddonConfig };
+
+export interface Addon {
+ id: string;
+ name: string;
+ version: string;
+ description?: string;
+ manifestUrl: string;
+ configurable: boolean;
+ config: AddonConfig;
+}
+
+export const api = {
+ getProfiles: () => request('GET', '/api/v1/profiles'),
+ getAddons: (profileId: string) => request('GET', `/api/v1/profiles/${profileId}/addons`),
+ installAddon: (manifestUrl: string) => request<{ id: string }>('POST', '/api/v1/addons', { manifestUrl }),
+ removeAddon: (addonId: string) => request('DELETE', `/api/v1/addons/${encodeURIComponent(addonId)}`),
+ updateAddonConfig: (profileId: string, addonId: string, config: Partial) =>
+ request('PATCH', `/api/v1/profiles/${profileId}/addons/${encodeURIComponent(addonId)}`, config),
+ reorderAddons: (profileId: string, orderedIds: string[]) =>
+ request('PUT', `/api/v1/profiles/${profileId}/addons/order`, { orderedIds }),
+};
diff --git a/remote-ui/src/components/AddonsView.tsx b/remote-ui/src/components/AddonsView.tsx
new file mode 100644
index 0000000..ba8a4d9
--- /dev/null
+++ b/remote-ui/src/components/AddonsView.tsx
@@ -0,0 +1,229 @@
+import React, { useState, useCallback } from 'react';
+import {
+ Trash2,
+ Settings,
+ Puzzle,
+ Plus,
+ AlertCircle,
+ Loader2,
+ AlertTriangle,
+ X,
+} from 'lucide-react';
+import { Profile, Addon } from '../api';
+import { useAllAddonsQuery, useInstallAddonMutation, useRemoveAddonMutation } from '../queries';
+
+interface AddonsViewProps {
+ profiles: Profile[];
+ onSessionEnded?: () => void;
+}
+
+interface AddonRowProps {
+ addon: Addon;
+ activeInProfiles: Profile[];
+ profiles: Profile[];
+ onRequestDelete: (addon: Addon) => void;
+}
+
+const AddonRow = React.memo(function AddonRow({
+ addon,
+ activeInProfiles,
+ profiles,
+ onRequestDelete,
+}: AddonRowProps) {
+ return (
+
+
+
+
+
+
{addon.name}
+ v{addon.version}
+
+
{addon.description}
+
+ {activeInProfiles.length > 0 && (
+
+ {activeInProfiles.map((p) => {
+ const profileIndex = profiles.findIndex((prof) => prof.id === p.id);
+ return (
+
+ {p.name.charAt(0).toUpperCase()}
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {addon.configurable && (
+
+
+
+ )}
+
+
+
+ );
+});
+
+export function AddonsView({ profiles, onSessionEnded }: AddonsViewProps) {
+ const [manifestUrl, setManifestUrl] = useState('');
+ const [pendingDelete, setPendingDelete] = useState(null);
+
+ const { addons, activeByProfile, isLoading, error } = useAllAddonsQuery(profiles, onSessionEnded);
+ const installMutation = useInstallAddonMutation(onSessionEnded);
+ const removeMutation = useRemoveAddonMutation(onSessionEnded);
+
+ const handleInstall = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!manifestUrl.trim()) return;
+ installMutation.mutate(manifestUrl.trim(), {
+ onSuccess: () => setManifestUrl(''),
+ });
+ };
+
+ const handleDelete = (addon: Addon) => {
+ removeMutation.mutate(addon.id, {
+ onSuccess: () => setPendingDelete(null),
+ });
+ };
+
+ const handleRequestDelete = useCallback((addon: Addon) => {
+ setPendingDelete(addon);
+ }, []);
+
+ const handleCancelDelete = useCallback(() => {
+ setPendingDelete(null);
+ }, []);
+
+ if (isLoading && addons.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
Addons
+
+ Addons are installed globally. Go to Profiles to activate and reorder per profile.
+
+
+
+
+
+ {error && (
+
+ )}
+
+ {installMutation.isError && (
+
+
+
+ {installMutation.error instanceof Error
+ ? installMutation.error.message
+ : 'Failed to install addon'}
+
+
+ )}
+
+ {removeMutation.isError && (
+
+
+
+ {removeMutation.error instanceof Error
+ ? removeMutation.error.message
+ : 'Failed to delete addon'}
+
+
+ )}
+
+ {pendingDelete && (
+
+
+
+ Delete {pendingDelete.name}? This
+ cannot be undone.
+
+
+
+
+ )}
+
+
+ {addons.length === 0 ? (
+
+
+
No addons installed yet.
+
+ ) : (
+
+ {addons.map((addon) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/remote-ui/src/components/BottomNav.tsx b/remote-ui/src/components/BottomNav.tsx
new file mode 100644
index 0000000..70f172e
--- /dev/null
+++ b/remote-ui/src/components/BottomNav.tsx
@@ -0,0 +1,30 @@
+import { Puzzle, Users } from 'lucide-react';
+
+export type ViewTab = 'addons' | 'profiles';
+
+interface BottomNavProps {
+ activeView: ViewTab;
+ onViewChange: (view: ViewTab) => void;
+}
+
+const TAB_BASE =
+ 'flex flex-1 flex-col items-center gap-1 py-3 transition-colors text-[10px] font-medium uppercase tracking-wider';
+
+export function BottomNav({ activeView, onViewChange }: BottomNavProps) {
+ return (
+
+ );
+}
diff --git a/remote-ui/src/components/DisconnectionBanner.tsx b/remote-ui/src/components/DisconnectionBanner.tsx
new file mode 100644
index 0000000..25f2f2a
--- /dev/null
+++ b/remote-ui/src/components/DisconnectionBanner.tsx
@@ -0,0 +1,12 @@
+import { WifiOff } from 'lucide-react';
+
+export function DisconnectionBanner() {
+ return (
+
+
+
+ Lost connection to DodoStream â make sure the app is open and Remote Control is active.
+
+
+ );
+}
diff --git a/remote-ui/src/components/DodoLogo.tsx b/remote-ui/src/components/DodoLogo.tsx
new file mode 100644
index 0000000..135acbb
--- /dev/null
+++ b/remote-ui/src/components/DodoLogo.tsx
@@ -0,0 +1,21 @@
+import { useId } from 'react';
+import { LOGO_PATH_D } from '@app/constants/logo-path';
+
+interface DodoLogoProps {
+ size?: number;
+}
+
+export function DodoLogo({ size = 28 }: DodoLogoProps) {
+ const gradientId = useId();
+ return (
+
+ );
+}
diff --git a/remote-ui/src/components/Header.tsx b/remote-ui/src/components/Header.tsx
new file mode 100644
index 0000000..8dbe3d7
--- /dev/null
+++ b/remote-ui/src/components/Header.tsx
@@ -0,0 +1,12 @@
+import { DodoLogo } from './DodoLogo';
+
+export function Header() {
+ return (
+
+
+
+
DodoStream Remote
+
+
+ );
+}
diff --git a/remote-ui/src/components/LoadingSpinner.tsx b/remote-ui/src/components/LoadingSpinner.tsx
new file mode 100644
index 0000000..fcf98e4
--- /dev/null
+++ b/remote-ui/src/components/LoadingSpinner.tsx
@@ -0,0 +1,10 @@
+import { Loader2 } from 'lucide-react';
+
+export function LoadingSpinner() {
+ return (
+
+ );
+}
diff --git a/remote-ui/src/components/PinDialog.tsx b/remote-ui/src/components/PinDialog.tsx
new file mode 100644
index 0000000..6dd8b1c
--- /dev/null
+++ b/remote-ui/src/components/PinDialog.tsx
@@ -0,0 +1,63 @@
+import { useState } from 'react';
+import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react';
+import { setPin } from '../api';
+
+interface PinDialogProps {
+ onSuccess: () => void;
+}
+
+export function PinDialog({ onSuccess }: PinDialogProps) {
+ const [pinInput, setPinInput] = useState('');
+ const [error, setError] = useState('');
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (pinInput.trim().length === 0) {
+ setError('Please enter a PIN');
+ return;
+ }
+
+ setPin(pinInput.trim());
+ onSuccess();
+ };
+
+ return (
+
+ );
+}
diff --git a/remote-ui/src/components/ProfileAddonRow.tsx b/remote-ui/src/components/ProfileAddonRow.tsx
new file mode 100644
index 0000000..6ed0414
--- /dev/null
+++ b/remote-ui/src/components/ProfileAddonRow.tsx
@@ -0,0 +1,175 @@
+import React, { useState } from 'react';
+import { GripVertical, Settings, ChevronDown, ChevronUp, Puzzle } from 'lucide-react';
+import { Switch } from '@headlessui/react';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { Addon, AddonConfig } from '../api';
+
+interface ProfileAddonRowProps {
+ addon: Addon;
+ isSortable: boolean;
+ onToggleActive: (addon: Addon, isActive: boolean) => void;
+ onUpdateConfig: (addon: Addon, configUpdates: Partial) => void;
+}
+
+const ProfileAddonRow = React.memo(function ProfileAddonRow({
+ addon,
+ isSortable,
+ onToggleActive,
+ onUpdateConfig,
+}: ProfileAddonRowProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const { config } = addon;
+
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: addon.id, disabled: !isSortable });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ zIndex: isDragging ? 10 : 1,
+ };
+
+ const hasSubConfigs = config.isActive;
+
+ return (
+
+
+
+ {isSortable ? (
+
+
+
+ ) : (
+
// Spacer to align with sortable items
+ )}
+
+
+
+
+
+
+ {addon.name}
+
+ v{addon.version}
+
+
{addon.description}
+
+
+
+
+ {addon.configurable && (
+
+
+
+
+ )}
+
+ {hasSubConfigs && (
+
+ )}
+
+
onToggleActive(addon, checked)}
+ className={`${
+ config.isActive ? 'bg-indigo-500' : 'bg-zinc-700'
+ } relative inline-flex h-5 w-9 sm:h-6 sm:w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-zinc-900 sm:ml-2`}
+ >
+
+
+
+
+
+ {hasSubConfigs && isExpanded && (
+
+
+ Visible on Home
+ onUpdateConfig(addon, { useCatalogsOnHome: checked })}
+ className={`${
+ config.useCatalogsOnHome !== false ? 'bg-indigo-500' : 'bg-zinc-700'
+ } relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none`}
+ >
+
+
+
+
+
+ Use in Search
+ onUpdateConfig(addon, { useCatalogsInSearch: checked })}
+ className={`${
+ config.useCatalogsInSearch !== false ? 'bg-indigo-500' : 'bg-zinc-700'
+ } relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none`}
+ >
+
+
+
+
+
+ Use for Subtitles
+ onUpdateConfig(addon, { useForSubtitles: checked })}
+ className={`${
+ config.useForSubtitles ? 'bg-indigo-500' : 'bg-zinc-700'
+ } relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none`}
+ >
+
+
+
+
+ )}
+
+ );
+});
+
+export { ProfileAddonRow };
\ No newline at end of file
diff --git a/remote-ui/src/components/ProfilesView.tsx b/remote-ui/src/components/ProfilesView.tsx
new file mode 100644
index 0000000..1d29a7e
--- /dev/null
+++ b/remote-ui/src/components/ProfilesView.tsx
@@ -0,0 +1,200 @@
+import React, { useState, useMemo } from 'react';
+import { Loader2, AlertCircle, Puzzle } from 'lucide-react';
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from '@dnd-kit/core';
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { Profile, Addon, AddonConfig } from '../api';
+import {
+ useProfileAddonsQuery,
+ useReorderAddonsMutation,
+ useToggleActiveMutation,
+ useUpdateAddonConfigMutation,
+} from '../queries';
+import { ProfileAddonRow } from './ProfileAddonRow';
+
+interface ProfilesViewProps {
+ profiles: Profile[];
+ onSessionEnded?: () => void;
+}
+
+export function ProfilesView({ profiles, onSessionEnded }: ProfilesViewProps) {
+ const [selectedProfileId, setSelectedProfileId] = useState(profiles[0]?.id ?? '');
+
+ const { addons, isLoading, error, refetch } = useProfileAddonsQuery(
+ selectedProfileId,
+ onSessionEnded,
+ );
+
+ const reorderMutation = useReorderAddonsMutation(selectedProfileId, onSessionEnded);
+ const toggleMutation = useToggleActiveMutation(selectedProfileId, onSessionEnded);
+ const configMutation = useUpdateAddonConfigMutation(selectedProfileId, onSessionEnded);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+ );
+
+ const activeAddons = useMemo(() => addons.filter((a) => a.config.isActive), [addons]);
+ const inactiveAddons = useMemo(() => addons.filter((a) => !a.config.isActive), [addons]);
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+
+ const oldIndex = activeAddons.findIndex((a) => a.id === active.id);
+ const newIndex = activeAddons.findIndex((a) => a.id === over.id);
+ const newActive = arrayMove(activeAddons, oldIndex, newIndex);
+
+ const orderedIds = [...newActive.map((a) => a.id), ...inactiveAddons.map((a) => a.id)];
+ reorderMutation.mutate(orderedIds);
+ };
+
+ const handleToggleActive = (addon: Addon, isActive: boolean) => {
+ toggleMutation.mutate({ addonId: addon.id, isActive });
+ };
+
+ const handleUpdateConfig = (addon: Addon, configUpdates: Partial) => {
+ configMutation.mutate({ addonId: addon.id, configUpdates });
+ };
+
+ if (profiles.length === 0) {
+ return (
+
+ No profiles found.
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
Profiles
+
+ Activate, configure, and reorder addons per profile.
+
+
+
+ {/* Profile Selector */}
+
+
Select Profile
+
+ {profiles.map(p => (
+
+ ))}
+
+
+
+ {error && (
+
+
+
+
+ )}
+
+ {/* Addons Sections */}
+ {isLoading && addons.length === 0 ? (
+
+
+
+ ) : addons.length === 0 ? (
+
+
+
No addons found for this profile.
+
+ ) : (
+
+
+ {/* Active Addons */}
+
+
+ Active Addons
+
+ {activeAddons.length}
+
+
+
+ {activeAddons.length === 0 ? (
+
No active addons. Enable some below.
+ ) : (
+
+ a.id)} strategy={verticalListSortingStrategy}>
+
+ {activeAddons.map(addon => (
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Inactive Addons */}
+ {inactiveAddons.length > 0 && (
+
+
+ Inactive Addons
+
+ {inactiveAddons.length}
+
+
+
+
+ {inactiveAddons.map(addon => (
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/remote-ui/src/components/SessionEndedScreen.tsx b/remote-ui/src/components/SessionEndedScreen.tsx
new file mode 100644
index 0000000..cfd6c46
--- /dev/null
+++ b/remote-ui/src/components/SessionEndedScreen.tsx
@@ -0,0 +1,17 @@
+import { WifiOff } from 'lucide-react';
+
+export function SessionEndedScreen() {
+ return (
+
+
+
+
+
+ Session Ended
+
+
+ Your connection has expired. Please restart the remote from your TV to generate a new link.
+
+
+ );
+}
diff --git a/remote-ui/src/index.css b/remote-ui/src/index.css
new file mode 100644
index 0000000..92a298d
--- /dev/null
+++ b/remote-ui/src/index.css
@@ -0,0 +1,9 @@
+@import "tailwindcss";
+
+@theme {
+ --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+}
+
+html, body, #root {
+ height: 100%;
+}
diff --git a/remote-ui/src/main.tsx b/remote-ui/src/main.tsx
new file mode 100644
index 0000000..c904710
--- /dev/null
+++ b/remote-ui/src/main.tsx
@@ -0,0 +1,22 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import './index.css';
+import App from './App';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000,
+ retry: false,
+ },
+ },
+});
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+);
diff --git a/remote-ui/src/queries.ts b/remote-ui/src/queries.ts
new file mode 100644
index 0000000..0336b1b
--- /dev/null
+++ b/remote-ui/src/queries.ts
@@ -0,0 +1,220 @@
+import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query';
+import { useEffect } from 'react';
+import { api, Addon, Profile, AddonConfig, isSessionEndedError } from './api';
+
+// âââ Query Keys ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
+
+export const queryKeys = {
+ profiles: {
+ all: ['profiles'] as const,
+ },
+ addons: {
+ all: ['addons'] as const,
+ profile: (profileId: string) => ['addons', profileId] as const,
+ },
+} as const;
+
+// âââ AddonsView Hooks âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
+
+/** Fetch addons across all profiles, merging into a flat list with per-addon profile membership. */
+export function useAllAddonsQuery(profiles: Profile[], onSessionEnded?: () => void) {
+ const queries = useQueries({
+ queries: profiles.map((p) => ({
+ queryKey: queryKeys.addons.profile(p.id),
+ queryFn: () => api.getAddons(p.id),
+ enabled: profiles.length > 0,
+ })),
+ });
+
+ const hasSessionError = queries.some((q) => q.isError && isSessionEndedError(q.error));
+ useEffect(() => {
+ if (hasSessionError) onSessionEnded?.();
+ }, [hasSessionError, onSessionEnded]);
+
+ const addonMap = new Map();
+ const activeByProfile: Record = {};
+
+ for (let i = 0; i < queries.length; i++) {
+ const data = queries[i].data;
+ if (!data) continue;
+ const profile = profiles[i];
+ for (const addon of data) {
+ if (!addonMap.has(addon.id)) {
+ addonMap.set(addon.id, addon);
+ activeByProfile[addon.id] = [];
+ }
+ if (addon.config.isActive) {
+ activeByProfile[addon.id].push(profile);
+ }
+ }
+ }
+
+ const firstError = queries.find((q) => q.isError)?.error;
+
+ return {
+ addons: Array.from(addonMap.values()),
+ activeByProfile,
+ isLoading: queries.some((q) => q.isLoading),
+ error: firstError instanceof Error ? firstError.message : null,
+ };
+}
+
+export function useInstallAddonMutation(onSessionEnded?: () => void) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (manifestUrl: string) => api.installAddon(manifestUrl),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.addons.all });
+ },
+ onError: (err: unknown) => {
+ if (isSessionEndedError(err)) onSessionEnded?.();
+ },
+ });
+}
+
+export function useRemoveAddonMutation(onSessionEnded?: () => void) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (addonId: string) => api.removeAddon(addonId),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.addons.all });
+ },
+ onError: (err: unknown) => {
+ if (isSessionEndedError(err)) onSessionEnded?.();
+ },
+ });
+}
+
+// âââ ProfilesView Hooks âââââââââââââââââââââââââââââââââââââââââââââââââââââââ
+
+export function useProfileAddonsQuery(profileId: string, onSessionEnded?: () => void) {
+ const query = useQuery({
+ queryKey: queryKeys.addons.profile(profileId),
+ queryFn: () => api.getAddons(profileId),
+ enabled: !!profileId,
+ });
+
+ const hasSessionError = query.isError && isSessionEndedError(query.error);
+ useEffect(() => {
+ if (hasSessionError) onSessionEnded?.();
+ }, [hasSessionError, onSessionEnded]);
+
+ return {
+ addons: query.data ?? [],
+ isLoading: query.isLoading,
+ error: query.isError
+ ? query.error instanceof Error
+ ? query.error.message
+ : 'Failed to load profile addons'
+ : null,
+ refetch: query.refetch,
+ };
+}
+
+export function useReorderAddonsMutation(profileId: string, onSessionEnded?: () => void) {
+ const queryClient = useQueryClient();
+ const key = queryKeys.addons.profile(profileId);
+
+ return useMutation({
+ mutationFn: (orderedIds: string[]) => api.reorderAddons(profileId, orderedIds),
+ onMutate: async (orderedIds) => {
+ await queryClient.cancelQueries({ queryKey: key });
+ const previousAddons = queryClient.getQueryData(key);
+
+ if (previousAddons) {
+ const byId = new Map(previousAddons.map((a) => [a.id, a]));
+ const reordered = orderedIds.map((id) => byId.get(id)!).filter(Boolean);
+ const reorderedSet = new Set(orderedIds);
+ const remaining = previousAddons.filter((a) => !reorderedSet.has(a.id));
+ queryClient.setQueryData(key, [...reordered, ...remaining]);
+ }
+
+ return { previousAddons };
+ },
+ onError: (err: unknown, _vars, context) => {
+ if (isSessionEndedError(err)) {
+ onSessionEnded?.();
+ } else if (context?.previousAddons) {
+ queryClient.setQueryData(key, context.previousAddons);
+ }
+ },
+ onSettled: () => {
+ void queryClient.invalidateQueries({ queryKey: key });
+ },
+ });
+}
+
+export function useToggleActiveMutation(profileId: string, onSessionEnded?: () => void) {
+ const queryClient = useQueryClient();
+ const key = queryKeys.addons.profile(profileId);
+
+ return useMutation({
+ mutationFn: ({ addonId, isActive }: { addonId: string; isActive: boolean }) =>
+ api.updateAddonConfig(profileId, addonId, { isActive }),
+ onMutate: async ({ addonId, isActive }) => {
+ await queryClient.cancelQueries({ queryKey: key });
+ const previousAddons = queryClient.getQueryData(key);
+
+ if (previousAddons) {
+ queryClient.setQueryData(
+ key,
+ previousAddons.map((a) =>
+ a.id === addonId ? { ...a, config: { ...a.config, isActive } } : a,
+ ),
+ );
+ }
+
+ return { previousAddons };
+ },
+ onError: (err: unknown, _vars, context) => {
+ if (isSessionEndedError(err)) {
+ onSessionEnded?.();
+ } else if (context?.previousAddons) {
+ queryClient.setQueryData(key, context.previousAddons);
+ }
+ },
+ onSettled: () => {
+ void queryClient.invalidateQueries({ queryKey: key });
+ },
+ });
+}
+
+export function useUpdateAddonConfigMutation(profileId: string, onSessionEnded?: () => void) {
+ const queryClient = useQueryClient();
+ const key = queryKeys.addons.profile(profileId);
+
+ return useMutation({
+ mutationFn: ({
+ addonId,
+ configUpdates,
+ }: {
+ addonId: string;
+ configUpdates: Partial;
+ }) => api.updateAddonConfig(profileId, addonId, configUpdates),
+ onMutate: async ({ addonId, configUpdates }) => {
+ await queryClient.cancelQueries({ queryKey: key });
+ const previousAddons = queryClient.getQueryData(key);
+
+ if (previousAddons) {
+ queryClient.setQueryData(
+ key,
+ previousAddons.map((a) =>
+ a.id === addonId ? { ...a, config: { ...a.config, ...configUpdates } } : a,
+ ),
+ );
+ }
+
+ return { previousAddons };
+ },
+ onError: (err: unknown, _vars, context) => {
+ if (isSessionEndedError(err)) {
+ onSessionEnded?.();
+ } else if (context?.previousAddons) {
+ queryClient.setQueryData(key, context.previousAddons);
+ }
+ },
+ onSettled: () => {
+ void queryClient.invalidateQueries({ queryKey: key });
+ },
+ });
+}
diff --git a/remote-ui/tsconfig.json b/remote-ui/tsconfig.json
new file mode 100644
index 0000000..aa0e5f7
--- /dev/null
+++ b/remote-ui/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "paths": {
+ "@app/*": ["../src/*"]
+ },
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true
+ },
+ "include": ["src", "../src/constants/logo-path.ts"]
+}
diff --git a/remote-ui/vite.config.ts b/remote-ui/vite.config.ts
new file mode 100644
index 0000000..e676464
--- /dev/null
+++ b/remote-ui/vite.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
+import { viteSingleFile } from 'vite-plugin-singlefile';
+import path from 'path';
+
+export default defineConfig({
+ plugins: [tailwindcss(), react(), viteSingleFile()],
+ resolve: {
+ alias: {
+ '@app': path.resolve(__dirname, '../src'),
+ },
+ },
+ build: {
+ outDir: 'dist',
+ assetsInlineLimit: 100000000,
+ cssCodeSplit: false,
+ },
+});
diff --git a/scripts/embed-web-ui.mjs b/scripts/embed-web-ui.mjs
new file mode 100644
index 0000000..a768ccc
--- /dev/null
+++ b/scripts/embed-web-ui.mjs
@@ -0,0 +1,15 @@
+#!/usr/bin/env node
+import { readFileSync, writeFileSync } from 'fs';
+import { resolve, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const root = resolve(__dirname, '..');
+
+const html = readFileSync(resolve(root, 'remote-ui/dist/index.html'), 'utf-8');
+const escaped = html.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
+
+const output = `// AUTO-GENERATED â do not edit. Run pnpm build:ui to regenerate.\nexport const WEB_UI_HTML = \`${escaped}\`;\n`;
+
+writeFileSync(resolve(root, 'src/api/local-server/web-ui.ts'), output, 'utf-8');
+console.log('â Embedded web UI into src/api/local-server/web-ui.ts');
diff --git a/src/api/local-server/__tests__/handlers.test.ts b/src/api/local-server/__tests__/handlers.test.ts
new file mode 100644
index 0000000..698aafb
--- /dev/null
+++ b/src/api/local-server/__tests__/handlers.test.ts
@@ -0,0 +1,606 @@
+import {
+ deleteAddon,
+ getProfileAddons,
+ getProfiles,
+ installAddon,
+ patchProfileAddon,
+ reorderProfileAddons,
+} from '../handlers';
+
+import { useAddonStore } from '@/store/addon.store';
+import { type AddonConfig } from '@/types/addon-config';
+import { useProfileStore } from '@/store/profile.store';
+
+jest.mock('@/store/addon.store', () => ({
+ useAddonStore: {
+ getState: jest.fn(),
+ },
+}));
+
+jest.mock('@/store/profile.store', () => ({
+ useProfileStore: {
+ getState: jest.fn(),
+ },
+}));
+
+
+interface MockProfile {
+ id: string;
+ name: string;
+ avatarIcon: string;
+ avatarColor: string;
+}
+
+interface MockInstalledAddon {
+ id: string;
+ manifestUrl: string;
+ manifest: {
+ id: string;
+ name: string;
+ version: string;
+ description?: string;
+ };
+}
+
+const mockUseProfileStore = useProfileStore as jest.Mocked;
+const mockUseAddonStore = useAddonStore as jest.Mocked;
+
+const defaultConfig: AddonConfig = {
+ isActive: true,
+ useCatalogsOnHome: true,
+ useCatalogsInSearch: true,
+ useForSubtitles: true,
+};
+
+function createAddonStoreState(overrides?: {
+ orderedAddons?: MockInstalledAddon[];
+ configByAddonId?: Record;
+}) {
+ const orderedAddons = overrides?.orderedAddons ?? [];
+ const configByAddonId = overrides?.configByAddonId ?? {};
+
+ const addAddon = jest.fn();
+ const removeAddon = jest.fn();
+ const activateAddon = jest.fn((addonId: string) => {
+ const cfg = configByAddonId[addonId];
+ if (cfg) {
+ configByAddonId[addonId] = { ...cfg, isActive: true };
+ }
+ });
+ const deactivateAddon = jest.fn((addonId: string) => {
+ const cfg = configByAddonId[addonId];
+ if (cfg) {
+ configByAddonId[addonId] = { ...cfg, isActive: false };
+ }
+ });
+ const reorderAddon = jest.fn((fromIndex: number, toIndex: number) => {
+ const [moved] = orderedAddons.splice(fromIndex, 1);
+ orderedAddons.splice(toIndex, 0, moved);
+ });
+ const toggleUseCatalogsOnHome = jest.fn((addonId: string) => {
+ const cfg = configByAddonId[addonId];
+ if (cfg) {
+ configByAddonId[addonId] = { ...cfg, useCatalogsOnHome: !cfg.useCatalogsOnHome };
+ }
+ });
+ const toggleUseCatalogsInSearch = jest.fn((addonId: string) => {
+ const cfg = configByAddonId[addonId];
+ if (cfg) {
+ configByAddonId[addonId] = { ...cfg, useCatalogsInSearch: !cfg.useCatalogsInSearch };
+ }
+ });
+ const toggleUseForSubtitles = jest.fn((addonId: string) => {
+ const cfg = configByAddonId[addonId];
+ if (cfg) {
+ configByAddonId[addonId] = { ...cfg, useForSubtitles: !cfg.useForSubtitles };
+ }
+ });
+ const getAddonConfig = jest.fn((addonId: string) => configByAddonId[addonId]);
+ const setAddonConfig = jest.fn((addonId: string, config: Partial) => {
+ const existing = configByAddonId[addonId] ?? defaultConfig;
+ configByAddonId[addonId] = { ...existing, ...config };
+ });
+ const getOrderedAddonsList = jest.fn(() => orderedAddons);
+ return {
+ addons: {},
+ configsByProfile: {},
+ addonOrderByProfile: {},
+ addAddon,
+ removeAddon,
+ activateAddon,
+ deactivateAddon,
+ reorderAddon,
+ toggleUseCatalogsOnHome,
+ toggleUseCatalogsInSearch,
+ toggleUseForSubtitles,
+ getAddonConfig,
+ setAddonConfig,
+ getOrderedAddonsList,
+ };
+}
+
+function setProfiles(profiles: Record) {
+ mockUseProfileStore.getState.mockReturnValue({ profiles } as never);
+}
+
+describe('local server handlers', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('getProfiles', () => {
+ it('returns 200 with mapped profile list', () => {
+ // Arrange
+ setProfiles({
+ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' },
+ p2: { id: 'p2', name: 'Bob', avatarIcon: 'robot', avatarColor: 'red' },
+ });
+
+ // Act
+ const result = getProfiles();
+
+ // Assert
+ expect(result).toEqual({
+ status: 200,
+ body: [
+ {
+ id: 'p1',
+ name: 'Alice',
+ color: 'blue',
+ },
+ {
+ id: 'p2',
+ name: 'Bob',
+ color: 'red',
+ },
+ ],
+ });
+ });
+
+ it('returns 200 with empty array when no profiles', () => {
+ // Arrange
+ setProfiles({});
+
+ // Act
+ const result = getProfiles();
+
+ // Assert
+ expect(result).toEqual({ status: 200, body: [] });
+ });
+ });
+
+ describe('getProfileAddons', () => {
+ it('returns 404 when profile not found', () => {
+ // Arrange
+ setProfiles({});
+
+ // Act
+ const result = getProfileAddons('missing-profile');
+
+ // Assert
+ expect(result).toEqual({ status: 404, body: { error: 'Profile not found' } });
+ });
+
+ it('returns 200 with addon list including config', () => {
+ // Arrange
+ setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } });
+
+ const addonStore = createAddonStoreState({
+ orderedAddons: [
+ {
+ id: 'addon.one',
+ manifestUrl: 'https://example.com/one/manifest.json',
+ manifest: {
+ id: 'addon.one',
+ name: 'Addon One',
+ version: '1.2.3',
+ description: 'First addon',
+ },
+ },
+ ],
+ configByAddonId: {
+ 'addon.one': { ...defaultConfig, useCatalogsOnHome: false },
+ },
+ });
+ mockUseAddonStore.getState.mockReturnValue(addonStore as never);
+
+ // Act
+ const result = getProfileAddons('p1');
+
+ // Assert
+ expect(result).toEqual({
+ status: 200,
+ body: [
+ {
+ id: 'addon.one',
+ name: 'Addon One',
+ version: '1.2.3',
+ description: 'First addon',
+ manifestUrl: 'https://example.com/one/manifest.json',
+ config: {
+ isActive: true,
+ useCatalogsOnHome: false,
+ useCatalogsInSearch: true,
+ useForSubtitles: true,
+ },
+ configurable: false
+ },
+ ],
+ });
+ });
+
+ it('returns 200 with empty array when profile has no addons', () => {
+ // Arrange
+ setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } });
+ const addonStore = createAddonStoreState();
+ mockUseAddonStore.getState.mockReturnValue(addonStore as never);
+
+ // Act
+ const result = getProfileAddons('p1');
+
+ // Assert
+ expect(result).toEqual({ status: 200, body: [] });
+ });
+ });
+
+ describe('installAddon', () => {
+ it('returns 400 when manifestUrl is missing', async () => {
+ // Act
+ const result = await installAddon({});
+
+ // Assert
+ expect(result).toEqual({ status: 400, body: { error: 'manifestUrl is required' } });
+ });
+
+ it('returns 400 when manifestUrl is not a string', async () => {
+ // Act
+ const result = await installAddon({ manifestUrl: 123 });
+
+ // Assert
+ expect(result).toEqual({ status: 400, body: { error: 'manifestUrl is required' } });
+ });
+
+ it('returns 400 when fetch fails with network error', async () => {
+ // Arrange
+ const fetchSpy = jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('network failed'));
+
+ // Act
+ const result = await installAddon({ manifestUrl: 'https://example.com/manifest.json' });
+
+ // Assert
+ expect(result).toEqual({ status: 400, body: { error: 'Failed to fetch manifest' } });
+ fetchSpy.mockRestore();
+ });
+
+ it('returns 400 when response is not ok', async () => {
+ // Arrange
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ } as Response);
+
+ // Act
+ const result = await installAddon({ manifestUrl: 'https://example.com/missing.json' });
+
+ // Assert
+ expect(result).toEqual({
+ status: 400,
+ body: { error: 'Failed to fetch manifest (404)' },
+ });
+ fetchSpy.mockRestore();
+ });
+
+ it('returns 400 when manifest is missing required fields', async () => {
+ // Arrange
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({ id: 'addon.invalid' }),
+ } as Response);
+
+ // Act
+ const result = await installAddon({ manifestUrl: 'https://example.com/manifest.json' });
+
+ // Assert
+ expect(result).toEqual({
+ status: 400,
+ body: { error: 'Invalid manifest: missing required fields' },
+ });
+ fetchSpy.mockRestore();
+ });
+
+ it('returns 200 with id and calls addAddon when manifest is valid', async () => {
+ // Arrange
+ const addonStore = createAddonStoreState();
+ mockUseAddonStore.getState.mockReturnValue(addonStore as never);
+
+ const manifest = {
+ id: 'addon.valid',
+ name: 'Valid Addon',
+ version: '1.0.0',
+ };
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => manifest,
+ } as Response);
+
+ // Act
+ const result = await installAddon({ manifestUrl: 'https://example.com/manifest.json' });
+
+ // Assert
+ expect(result).toEqual({ status: 200, body: { id: 'addon.valid' } });
+ expect(addonStore.addAddon).toHaveBeenCalledWith(
+ 'addon.valid',
+ 'https://example.com/manifest.json',
+ manifest
+ );
+ fetchSpy.mockRestore();
+ });
+ });
+
+ describe('deleteAddon', () => {
+ it('calls removeAddon with decoded addonId and returns 204', () => {
+ // Arrange
+ const addonStore = createAddonStoreState();
+ mockUseAddonStore.getState.mockReturnValue(addonStore as never);
+
+ // Act
+ const result = deleteAddon('my%2Faddon');
+
+ // Assert
+ expect(addonStore.removeAddon).toHaveBeenCalledWith('my%2Faddon');
+ expect(result).toEqual({ status: 204 });
+ });
+ });
+
+ describe('patchProfileAddon', () => {
+ it('returns 404 when profile not found', () => {
+ // Arrange
+ setProfiles({});
+
+ // Act
+ const result = patchProfileAddon('missing-profile', 'addon.one', {});
+
+ // Assert
+ expect(result).toEqual({ status: 404, body: { error: 'Profile not found' } });
+ });
+
+ it('returns 200 with no-op when body is empty', () => {
+ // Arrange
+ setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } });
+ const addonStore = createAddonStoreState({
+ configByAddonId: {
+ 'addon.one': { ...defaultConfig },
+ },
+ });
+ mockUseAddonStore.getState.mockReturnValue(addonStore as never);
+
+ // Act
+ const result = patchProfileAddon('p1', 'addon.one', {});
+
+ // Assert
+ expect(addonStore.setAddonConfig).not.toHaveBeenCalled();
+ expect(result).toEqual({ status: 200, body: { ok: true } });
+ });
+
+ it('calls setAddonConfig with isActive: true', () => {
+ // Arrange
+ setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } });
+ const addonStore = createAddonStoreState({
+ configByAddonId: {
+ 'addon.one': { ...defaultConfig, isActive: false },
+ },
+ });
+ mockUseAddonStore.getState.mockReturnValue(addonStore as never);
+
+ // Act
+ const result = patchProfileAddon('p1', 'addon.one', { isActive: true });
+
+ // Assert
+ expect(addonStore.setAddonConfig).toHaveBeenCalledWith(
+ 'addon.one',
+ { isActive: true },
+ 'p1',
+ );
+ expect(result).toEqual({ status: 200, body: { ok: true } });
+ });
+
+ it('calls setAddonConfig with isActive: false when config exists', () => {
+ // Arrange
+ setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } });
+ const addonStore = createAddonStoreState({
+ configByAddonId: {
+ 'addon.one': { ...defaultConfig, isActive: true },
+ },
+ });
+ mockUseAddonStore.getState.mockReturnValue(addonStore as never);
+
+ // Act
+ const result = patchProfileAddon('p1', 'addon.one', { isActive: false });
+
+ // Assert
+ expect(addonStore.setAddonConfig).toHaveBeenCalledWith(
+ 'addon.one',
+ { isActive: false },
+ 'p1',
+ );
+ expect(result).toEqual({ status: 200, body: { ok: true } });
+ });
+
+ it('returns 404 when deactivating and no config exists', () => {
+ // Arrange
+ setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } });
+ const addonStore = createAddonStoreState({
+ configByAddonId: { 'addon.one': undefined },
+ });
+ mockUseAddonStore.getState.mockReturnValue(addonStore as never);
+
+ // Act
+ const result = patchProfileAddon('p1', 'addon.one', { isActive: false });
+
+ // Assert
+ expect(addonStore.setAddonConfig).not.toHaveBeenCalled();
+ expect(result).toEqual({ status: 404, body: { error: 'Addon config not found' } });
+ });
+
+ it('calls setAddonConfig with useCatalogsOnHome', () => {
+ // Arrange
+ setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } });
+ const addonStore = createAddonStoreState({
+ configByAddonId: {
+ 'addon.one': { ...defaultConfig, useCatalogsOnHome: true },
+ },
+ });
+ mockUseAddonStore.getState.mockReturnValue(addonStore as never);
+
+ // Act
+ patchProfileAddon('p1', 'addon.one', { useCatalogsOnHome: false });
+
+ // Assert
+ expect(addonStore.setAddonConfig).toHaveBeenCalledWith(
+ 'addon.one',
+ { useCatalogsOnHome: false },
+ 'p1',
+ );
+ });
+
+ it('calls setAddonConfig with useCatalogsInSearch', () => {
+ // Arrange
+ setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } });
+ const addonStore = createAddonStoreState({
+ configByAddonId: {
+ 'addon.one': { ...defaultConfig, useCatalogsInSearch: false },
+ },
+ });
+ mockUseAddonStore.getState.mockReturnValue(addonStore as never);
+
+ // Act
+ patchProfileAddon('p1', 'addon.one', { useCatalogsInSearch: true });
+
+ // Assert
+ expect(addonStore.setAddonConfig).toHaveBeenCalledWith(
+ 'addon.one',
+ { useCatalogsInSearch: true },
+ 'p1',
+ );
+ });
+
+ it('calls setAddonConfig with useForSubtitles', () => {
+ // Arrange
+ setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } });
+ const addonStore = createAddonStoreState({
+ configByAddonId: {
+ 'addon.one': { ...defaultConfig, useForSubtitles: false },
+ },
+ });
+ mockUseAddonStore.getState.mockReturnValue(addonStore as never);
+
+ // Act
+ const result = patchProfileAddon('p1', 'addon.one', { useForSubtitles: true });
+
+ // Assert
+ expect(addonStore.setAddonConfig).toHaveBeenCalledWith(
+ 'addon.one',
+ { useForSubtitles: true },
+ 'p1',
+ );
+ expect(result).toEqual({ status: 200, body: { ok: true } });
+ });
+
+ it('calls setAddonConfig with multiple fields at once', () => {
+ // Arrange
+ setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } });
+ const addonStore = createAddonStoreState({
+ configByAddonId: {
+ 'addon.one': { ...defaultConfig },
+ },
+ });
+ mockUseAddonStore.getState.mockReturnValue(addonStore as never);
+
+ // Act
+ patchProfileAddon('p1', 'addon.one', {
+ isActive: false,
+ useCatalogsOnHome: false,
+ useForSubtitles: false,
+ });
+
+ // Assert
+ expect(addonStore.setAddonConfig).toHaveBeenCalledWith(
+ 'addon.one',
+ {
+ isActive: false,
+ useCatalogsOnHome: false,
+ useForSubtitles: false,
+ },
+ 'p1',
+ );
+ });
+ });
+
+ describe('reorderProfileAddons', () => {
+ it('returns 404 when profile not found', () => {
+ // Arrange
+ setProfiles({});
+
+ // Act
+ const result = reorderProfileAddons('missing-profile', { orderedIds: [] });
+
+ // Assert
+ expect(result).toEqual({ status: 404, body: { error: 'Profile not found' } });
+ });
+
+ it('returns 400 when orderedIds is not an array', () => {
+ // Arrange
+ setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } });
+
+ // Act
+ const result = reorderProfileAddons('p1', { orderedIds: 'not-an-array' });
+
+ // Assert
+ expect(result).toEqual({ status: 400, body: { error: 'orderedIds must be an array of strings' } });
+ });
+
+ it('calls reorderAddon with correct fromIndex/toIndex for moved items and returns 200', () => {
+ // Arrange
+ setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } });
+
+ const addonStore = createAddonStoreState({
+ orderedAddons: [
+ {
+ id: 'addon/a',
+ manifestUrl: 'https://example.com/a.json',
+ manifest: { id: 'addon/a', name: 'A', version: '1.0.0' },
+ },
+ {
+ id: 'addon/b',
+ manifestUrl: 'https://example.com/b.json',
+ manifest: { id: 'addon/b', name: 'B', version: '1.0.0' },
+ },
+ {
+ id: 'addon/c',
+ manifestUrl: 'https://example.com/c.json',
+ manifest: { id: 'addon/c', name: 'C', version: '1.0.0' },
+ },
+ {
+ id: 'addon/d',
+ manifestUrl: 'https://example.com/d.json',
+ manifest: { id: 'addon/d', name: 'D', version: '1.0.0' },
+ },
+ ],
+ });
+ mockUseAddonStore.getState.mockReturnValue(addonStore as never);
+
+ // Act
+ const result = reorderProfileAddons('p1', {
+ orderedIds: ['addon%2Fd', 'addon%2Fb', 'addon%2Fa', 'addon%2Fc'],
+ });
+
+ // Assert
+ expect(addonStore.reorderAddon).toHaveBeenCalledTimes(2);
+ expect(addonStore.reorderAddon).toHaveBeenNthCalledWith(1, 3, 0, 'p1');
+ expect(addonStore.reorderAddon).toHaveBeenNthCalledWith(2, 2, 1, 'p1');
+ expect(result).toEqual({ status: 200, body: { ok: true } });
+ });
+ });
+});
diff --git a/src/api/local-server/__tests__/router.test.ts b/src/api/local-server/__tests__/router.test.ts
new file mode 100644
index 0000000..6d3e3e9
--- /dev/null
+++ b/src/api/local-server/__tests__/router.test.ts
@@ -0,0 +1,248 @@
+import type { HttpRequest } from 'react-native-nitro-http-server';
+
+import {
+ deleteAddon,
+ getProfileAddons,
+ getProfiles,
+ installAddon,
+ patchProfileAddon,
+ reorderProfileAddons,
+} from '../handlers';
+import { createRouter } from '../router';
+
+jest.mock('../handlers', () => ({
+ getProfiles: jest.fn(),
+ getProfileAddons: jest.fn(),
+ installAddon: jest.fn(),
+ deleteAddon: jest.fn(),
+ patchProfileAddon: jest.fn(),
+ reorderProfileAddons: jest.fn(),
+}));
+
+function req(
+ method: string,
+ path: string,
+ opts: { headers?: Record; body?: string } = {}
+): HttpRequest {
+ return { requestId: '1', method, path, headers: opts.headers ?? {}, body: opts.body };
+}
+
+describe('local-server router', () => {
+ const PIN = '123456';
+ const HTML = 'test';
+
+ const getProfilesMock = jest.mocked(getProfiles);
+ const getProfileAddonsMock = jest.mocked(getProfileAddons);
+ const installAddonMock = jest.mocked(installAddon);
+ const deleteAddonMock = jest.mocked(deleteAddon);
+ const patchProfileAddonMock = jest.mocked(patchProfileAddon);
+ const reorderProfileAddonsMock = jest.mocked(reorderProfileAddons);
+
+ let router: ReturnType;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ router = createRouter(PIN, HTML);
+
+ getProfilesMock.mockReturnValue({ status: 200, body: { ok: true } });
+ getProfileAddonsMock.mockReturnValue({ status: 200, body: { ok: true } });
+ installAddonMock.mockResolvedValue({ status: 200, body: { ok: true } });
+ deleteAddonMock.mockReturnValue({ status: 204 });
+ patchProfileAddonMock.mockReturnValue({ status: 200, body: { ok: true } });
+ reorderProfileAddonsMock.mockReturnValue({ status: 200, body: { ok: true } });
+ });
+
+ describe('Web UI', () => {
+ it('GET / returns HTML page', async () => {
+ // Arrange
+ const request = req('GET', '/');
+
+ // Act
+ const response = await router(request);
+
+ // Assert
+ expect(response.statusCode).toBe(200);
+ expect(response.headers?.['Content-Type']).toBe('text/html; charset=utf-8');
+ expect(response.body).toBe(HTML);
+ });
+
+ it('GET / with trailing slash still serves web UI', async () => {
+ // Arrange
+ const request = req('GET', '///');
+
+ // Act
+ const response = await router(request);
+
+ // Assert
+ expect(response.statusCode).toBe(200);
+ expect(response.headers?.['Content-Type']).toBe('text/html; charset=utf-8');
+ expect(response.body).toBe(HTML);
+ });
+ });
+
+ describe('Auth', () => {
+ it('rejects /api route without auth', async () => {
+ const response = await router(req('GET', '/api/v1/profiles'));
+
+ expect(response.statusCode).toBe(401);
+ expect(response.headers?.['Content-Type']).toBe('application/json; charset=utf-8');
+ expect(response.body).toBe(JSON.stringify({ error: 'Unauthorized' }));
+ });
+
+ it('rejects wrong Bearer token', async () => {
+ const response = await router(
+ req('GET', '/api/v1/profiles', { headers: { Authorization: 'Bearer wrong' } })
+ );
+
+ expect(response.statusCode).toBe(401);
+ });
+
+ it('accepts correct Bearer token', async () => {
+ const response = await router(
+ req('GET', '/api/v1/profiles', { headers: { Authorization: `Bearer ${PIN}` } })
+ );
+
+ expect(response.statusCode).toBe(200);
+ });
+
+ it('accepts pin in query param', async () => {
+ const response = await router(req('GET', `/api/v1/profiles?pin=${PIN}`));
+
+ expect(response.statusCode).toBe(200);
+ });
+
+ it('rejects wrong pin in query param', async () => {
+ const response = await router(req('GET', '/api/v1/profiles?pin=WRONG'));
+
+ expect(response.statusCode).toBe(401);
+ });
+
+ it('accepts lowercase authorization header', async () => {
+ const response = await router(
+ req('GET', '/api/v1/profiles', { headers: { authorization: `Bearer ${PIN}` } })
+ );
+
+ expect(response.statusCode).toBe(200);
+ });
+ });
+
+ describe('Route matching', () => {
+ const auth = { Authorization: `Bearer ${PIN}` };
+
+ it('GET /api/v1/profiles calls getProfiles', async () => {
+ const response = await router(req('GET', '/api/v1/profiles', { headers: auth }));
+
+ expect(response.statusCode).toBe(200);
+ expect(getProfilesMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('GET /api/v1/profiles/:profileId/addons calls getProfileAddons', async () => {
+ const response = await router(req('GET', '/api/v1/profiles/p1/addons', { headers: auth }));
+
+ expect(response.statusCode).toBe(200);
+ expect(getProfileAddonsMock).toHaveBeenCalledWith('p1');
+ });
+
+ it('POST /api/v1/addons calls installAddon with parsed body', async () => {
+ const payload = { transportUrl: 'https://example.com/addon' };
+
+ const response = await router(
+ req('POST', '/api/v1/addons', { headers: auth, body: JSON.stringify(payload) })
+ );
+
+ expect(response.statusCode).toBe(200);
+ expect(installAddonMock).toHaveBeenCalledWith(payload);
+ });
+
+ it('DELETE /api/v1/addons/:addonId calls deleteAddon', async () => {
+ const response = await router(req('DELETE', '/api/v1/addons/some-addon', { headers: auth }));
+
+ expect(response.statusCode).toBe(204);
+ expect(deleteAddonMock).toHaveBeenCalledWith('some-addon');
+ });
+
+ it('PATCH /api/v1/profiles/:profileId/addons/:addonId calls patchProfileAddon', async () => {
+ const payload = { enabled: true };
+
+ const response = await router(
+ req('PATCH', '/api/v1/profiles/p1/addons/addon1', {
+ headers: auth,
+ body: JSON.stringify(payload),
+ })
+ );
+
+ expect(response.statusCode).toBe(200);
+ expect(patchProfileAddonMock).toHaveBeenCalledWith('p1', 'addon1', payload);
+ });
+
+ it('PUT /api/v1/profiles/:profileId/addons/order calls reorderProfileAddons', async () => {
+ const payload = { addonIds: ['a1', 'a2'] };
+
+ const response = await router(
+ req('PUT', '/api/v1/profiles/p1/addons/order', {
+ headers: auth,
+ body: JSON.stringify(payload),
+ })
+ );
+
+ expect(response.statusCode).toBe(200);
+ expect(reorderProfileAddonsMock).toHaveBeenCalledWith('p1', payload);
+ });
+
+ it('unknown route returns 404', async () => {
+ const response = await router(req('GET', '/api/v1/unknown', { headers: auth }));
+
+ expect(response.statusCode).toBe(404);
+ expect(response.headers?.['Content-Type']).toBe('application/json; charset=utf-8');
+ expect(response.body).toBe(JSON.stringify({ error: 'Not found' }));
+ });
+ });
+
+ describe('Error handling', () => {
+ const auth = { Authorization: `Bearer ${PIN}` };
+
+ it('returns 400 when handler throws SyntaxError', async () => {
+ installAddonMock.mockRejectedValueOnce(new SyntaxError('invalid json'));
+
+ const response = await router(
+ req('POST', '/api/v1/addons', { headers: auth, body: JSON.stringify({ a: 1 }) })
+ );
+
+ expect(response.statusCode).toBe(400);
+ expect(response.headers?.['Content-Type']).toBe('application/json; charset=utf-8');
+ expect(response.body).toBe(JSON.stringify({ error: 'Invalid JSON body' }));
+ });
+
+ it('returns 500 when handler throws generic error', async () => {
+ installAddonMock.mockRejectedValueOnce(new Error('boom'));
+
+ const response = await router(
+ req('POST', '/api/v1/addons', { headers: auth, body: JSON.stringify({ a: 1 }) })
+ );
+
+ expect(response.statusCode).toBe(500);
+ expect(response.headers?.['Content-Type']).toBe('application/json; charset=utf-8');
+ expect(response.body).toBe(JSON.stringify({ error: 'Internal server error' }));
+ });
+ });
+
+ describe('Response format', () => {
+ const auth = { Authorization: `Bearer ${PIN}` };
+
+ it('204 response has empty body', async () => {
+ const response = await router(req('DELETE', '/api/v1/addons/some-addon', { headers: auth }));
+
+ expect(response.statusCode).toBe(204);
+ expect(response.body).toBe('');
+ expect(response.headers).toEqual({});
+ });
+
+ it('JSON responses have application/json content type', async () => {
+ const response = await router(req('GET', '/api/v1/profiles', { headers: auth }));
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers?.['Content-Type']).toBe('application/json; charset=utf-8');
+ expect(response.body).toBe(JSON.stringify({ ok: true }));
+ });
+ });
+});
diff --git a/src/api/local-server/handlers.ts b/src/api/local-server/handlers.ts
new file mode 100644
index 0000000..b4e1b95
--- /dev/null
+++ b/src/api/local-server/handlers.ts
@@ -0,0 +1,162 @@
+import { useAddonStore } from '@/store/addon.store';
+import { type AddonConfig } from '@/types/addon-config';
+import { useProfileStore } from '@/store/profile.store';
+import type { Manifest } from '@/types/stremio';
+
+export interface RouteResult {
+ status: number;
+ body?: unknown;
+}
+
+interface AddAddonRequestBody {
+ manifestUrl: string;
+}
+
+interface PatchAddonConfigBody {
+ isActive?: boolean;
+ useCatalogsOnHome?: boolean;
+ useCatalogsInSearch?: boolean;
+ useForSubtitles?: boolean;
+}
+
+interface ReorderAddonsBody {
+ orderedIds: string[];
+}
+
+function badRequest(message: string): RouteResult {
+ return { status: 400, body: { error: message } };
+}
+
+export function getProfiles(): RouteResult {
+ const { profiles } = useProfileStore.getState();
+ const data = Object.values(profiles).map((profile) => ({
+ id: profile.id,
+ name: profile.name,
+ color: profile.avatarColor,
+ }));
+
+ return { status: 200, body: data };
+}
+
+export function getProfileAddons(profileId: string): RouteResult {
+ const { profiles } = useProfileStore.getState();
+ if (!profiles[profileId]) {
+ return { status: 404, body: { error: 'Profile not found' } };
+ }
+
+ const addonStore = useAddonStore.getState();
+ const addons = addonStore.getOrderedAddonsList(profileId).map((addon) => ({
+ id: addon.id,
+ name: addon.manifest.name,
+ version: addon.manifest.version,
+ description: addon.manifest.description,
+ configurable: addon.manifest.behaviorHints?.configurable ?? false,
+ manifestUrl: addon.manifestUrl,
+ config: addonStore.getAddonConfig(addon.id, profileId) ?? { isActive: false, useCatalogsOnHome: true, useCatalogsInSearch: true, useForSubtitles: true },
+ }));
+
+ return { status: 200, body: addons };
+}
+
+export async function installAddon(input: unknown): Promise {
+ const body = input as AddAddonRequestBody;
+ if (!body?.manifestUrl || typeof body.manifestUrl !== 'string') {
+ return badRequest('manifestUrl is required');
+ }
+
+ let manifest: Manifest;
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 10_000);
+ const response = await fetch(body.manifestUrl, {
+ headers: { Accept: 'application/json' },
+ signal: controller.signal,
+ }).finally(() => clearTimeout(timeoutId));
+ if (!response.ok) {
+ return { status: 400, body: { error: `Failed to fetch manifest (${response.status})` } };
+ }
+ manifest = (await response.json()) as Manifest;
+ } catch {
+ return { status: 400, body: { error: 'Failed to fetch manifest' } };
+ }
+
+ if (!manifest?.id || !manifest?.name || !manifest?.version) {
+ return badRequest('Invalid manifest: missing required fields');
+ }
+
+ useAddonStore.getState().addAddon(manifest.id, body.manifestUrl, manifest);
+ return { status: 200, body: { id: manifest.id } };
+}
+
+export function deleteAddon(addonId: string): RouteResult {
+ useAddonStore.getState().removeAddon(addonId);
+ return { status: 204 };
+}
+
+export function patchProfileAddon(
+ profileId: string,
+ addonId: string,
+ input: unknown
+): RouteResult {
+ const { profiles } = useProfileStore.getState();
+ if (!profiles[profileId]) {
+ return { status: 404, body: { error: 'Profile not found' } };
+ }
+
+ const body = (input ?? {}) as PatchAddonConfigBody;
+ const store = useAddonStore.getState();
+
+ // Build the partial config from explicitly-provided boolean fields.
+ const updates: Partial = {};
+ if (typeof body.isActive === 'boolean') updates.isActive = body.isActive;
+ if (typeof body.useCatalogsOnHome === 'boolean') updates.useCatalogsOnHome = body.useCatalogsOnHome;
+ if (typeof body.useCatalogsInSearch === 'boolean') updates.useCatalogsInSearch = body.useCatalogsInSearch;
+ if (typeof body.useForSubtitles === 'boolean') updates.useForSubtitles = body.useForSubtitles;
+
+ if (Object.keys(updates).length === 0) {
+ return { status: 200, body: { ok: true } };
+ }
+
+ // Deactivation requires an existing config entry.
+ if (updates.isActive === false) {
+ const currentConfig = store.getAddonConfig(addonId, profileId);
+ if (!currentConfig) {
+ return { status: 404, body: { error: 'Addon config not found' } };
+ }
+ }
+
+ // setAddonConfig upserts the entry with inactive defaults when no config exists,
+ store.setAddonConfig(addonId, updates, profileId);
+
+ return { status: 200, body: { ok: true } };
+}
+
+export function reorderProfileAddons(profileId: string, input: unknown): RouteResult {
+ const { profiles } = useProfileStore.getState();
+ if (!profiles[profileId]) {
+ return { status: 404, body: { error: 'Profile not found' } };
+ }
+
+ const body = input as ReorderAddonsBody;
+ if (!Array.isArray(body?.orderedIds) || !body.orderedIds.every((id) => typeof id === 'string')) {
+ return badRequest('orderedIds must be an array of strings');
+ }
+
+ const store = useAddonStore.getState();
+ const currentIds = store.getOrderedAddonsList(profileId).map((addon) => addon.id);
+ const workingOrder = [...currentIds];
+
+ for (let targetIndex = 0; targetIndex < body.orderedIds.length; targetIndex += 1) {
+ const addonId = decodeURIComponent(body.orderedIds[targetIndex]);
+ const fromIndex = workingOrder.indexOf(addonId);
+ if (fromIndex === -1) continue;
+ if (fromIndex === targetIndex) continue;
+
+ store.reorderAddon(fromIndex, targetIndex, profileId);
+
+ workingOrder.splice(fromIndex, 1);
+ workingOrder.splice(targetIndex, 0, addonId);
+ }
+
+ return { status: 200, body: { ok: true } };
+}
diff --git a/src/api/local-server/router.ts b/src/api/local-server/router.ts
new file mode 100644
index 0000000..fa56e92
--- /dev/null
+++ b/src/api/local-server/router.ts
@@ -0,0 +1,124 @@
+import type { HttpRequest, HttpResponse } from 'react-native-nitro-http-server';
+
+import {
+ deleteAddon,
+ getProfileAddons,
+ getProfiles,
+ installAddon,
+ patchProfileAddon,
+ reorderProfileAddons,
+} from './handlers';
+
+// âââ Helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
+
+function json(statusCode: number, body: unknown): HttpResponse {
+ return {
+ statusCode,
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
+ body: JSON.stringify(body),
+ };
+}
+
+function toResponse(result: { status: number; body?: unknown }): HttpResponse {
+ if (result.status === 204) {
+ return { statusCode: 204, headers: {}, body: '' };
+ }
+ return json(result.status, result.body ?? {});
+}
+
+function parseBody(raw: string | undefined): unknown {
+ if (!raw) return {};
+ return JSON.parse(raw) as unknown;
+}
+
+function getAuthPin(req: HttpRequest, rawPath: string): string {
+ const authHeader =
+ Object.entries(req.headers).find(([k]) => k.toLowerCase() === 'authorization')?.[1] ?? '';
+ if (authHeader.startsWith('Bearer ')) return authHeader.slice(7);
+ const qs = rawPath.includes('?') ? rawPath.slice(rawPath.indexOf('?') + 1) : '';
+ return new URLSearchParams(qs).get('pin') ?? '';
+}
+
+// âââ Route table ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
+
+type RouteHandler = (
+ params: Record,
+ req: HttpRequest
+) => Promise | HttpResponse;
+
+interface Route {
+ method: string;
+ pattern: RegExp;
+ paramNames: string[];
+ handler: RouteHandler;
+}
+
+function route(method: string, path: string, handler: RouteHandler): Route {
+ // Convert :param segments to named capture groups
+ const paramNames: string[] = [];
+ const regexStr = path.replace(/:([^/]+)/g, (_, name: string) => {
+ paramNames.push(name);
+ return '([^/]+)';
+ });
+ return { method, pattern: new RegExp(`^${regexStr}$`), paramNames, handler };
+}
+
+const ROUTES: Route[] = [
+ route('GET', '/api/v1/profiles', () => toResponse(getProfiles())),
+ route('GET', '/api/v1/profiles/:profileId/addons', ({ profileId }) =>
+ toResponse(getProfileAddons(decodeURIComponent(profileId)))
+ ),
+ route('POST', '/api/v1/addons', (_, req) => installAddon(parseBody(req.body)).then(toResponse)),
+ route('DELETE', '/api/v1/addons/:addonId', ({ addonId }) =>
+ toResponse(deleteAddon(decodeURIComponent(addonId)))
+ ),
+ route('PATCH', '/api/v1/profiles/:profileId/addons/:addonId', ({ profileId, addonId }, req) =>
+ toResponse(patchProfileAddon(decodeURIComponent(profileId), decodeURIComponent(addonId), parseBody(req.body)))
+ ),
+ route('PUT', '/api/v1/profiles/:profileId/addons/order', ({ profileId }, req) =>
+ toResponse(reorderProfileAddons(decodeURIComponent(profileId), parseBody(req.body)))
+ ),
+];
+
+// âââ Router factory âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
+
+export function createRouter(pin: string, webUiHtml: string) {
+ return async (req: HttpRequest): Promise => {
+ const method = req.method.toUpperCase();
+ const rawPath = req.path ?? '/';
+ const path = rawPath.split('?')[0].replace(/\/+$/, '') || '/';
+
+ // Serve web UI
+ if (method === 'GET' && path === '/') {
+ return {
+ statusCode: 200,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ body: webUiHtml,
+ };
+ }
+
+ // Auth check for API routes
+ if (path.startsWith('/api/')) {
+ if (getAuthPin(req, rawPath) !== pin) {
+ return json(401, { error: 'Unauthorized' });
+ }
+ }
+
+ // Match route table
+ for (const { method: m, pattern, paramNames, handler } of ROUTES) {
+ if (m !== method) continue;
+ const match = pattern.exec(path);
+ if (!match) continue;
+
+ const params = Object.fromEntries(paramNames.map((name, i) => [name, match[i + 1]]));
+ try {
+ return await handler(params, req);
+ } catch (err) {
+ if (err instanceof SyntaxError) return json(400, { error: 'Invalid JSON body' });
+ return json(500, { error: 'Internal server error' });
+ }
+ }
+
+ return json(404, { error: 'Not found' });
+ };
+}
diff --git a/src/api/local-server/server.ts b/src/api/local-server/server.ts
new file mode 100644
index 0000000..4242ea9
--- /dev/null
+++ b/src/api/local-server/server.ts
@@ -0,0 +1,24 @@
+import { HttpServer } from 'react-native-nitro-http-server';
+
+import { createRouter } from './router';
+import { WEB_UI_HTML } from './web-ui';
+
+const PORT = 7676;
+
+let serverInstance: HttpServer | null = null;
+
+export async function startLocalServer(pin: string): Promise {
+ if (serverInstance) return;
+
+ const router = createRouter(pin, WEB_UI_HTML);
+ serverInstance = new HttpServer();
+ await serverInstance.start(PORT, router, '0.0.0.0');
+}
+
+export async function stopLocalServer(): Promise {
+ if (!serverInstance) return;
+ await serverInstance.stop();
+ serverInstance = null;
+}
+
+export const LOCAL_SERVER_PORT = PORT;
diff --git a/src/api/stremio/__tests__/hooks.test.ts b/src/api/stremio/__tests__/hooks.test.ts
index 85bbae7..49c3829 100644
--- a/src/api/stremio/__tests__/hooks.test.ts
+++ b/src/api/stremio/__tests__/hooks.test.ts
@@ -17,11 +17,10 @@ jest.mock('../client', () => ({
const mockAddAddon = jest.fn();
const mockGetAddonsList = jest.fn();
jest.mock('@/store/addon.store', () => ({
- DEFAULT_ADDON_CONFIG: { isActive: true },
useAddonStore: jest.fn((selector: any) =>
selector({
addAddon: mockAddAddon,
- configsByProfile: {},
+ configsByProfile: { 'profile-1': { 'addon.id': { isActive: true, useCatalogsOnHome: true, useCatalogsInSearch: true, useForSubtitles: true } } },
getAddonsList: mockGetAddonsList,
})
),
diff --git a/src/api/stremio/hooks.ts b/src/api/stremio/hooks.ts
index 1a39994..207af11 100644
--- a/src/api/stremio/hooks.ts
+++ b/src/api/stremio/hooks.ts
@@ -14,7 +14,7 @@ import {
fetchCatalog,
fetchSubtitles,
} from './client';
-import { useAddonStore, DEFAULT_ADDON_CONFIG } from '@/store/addon.store';
+import { useAddonStore } from '@/store/addon.store';
import { useProfileStore } from '@/store/profile.store';
import { AddonSubtitle, ContentType, InstalledAddon, MetaPreview, Stream } from '@/types/stremio';
import { useDebugLogger } from '@/utils/debug';
@@ -282,7 +282,7 @@ export function useSearchCatalogs(query: string, enabled: boolean = true) {
const searchableCatalogs = addons
.filter((addon) => {
const config = activeProfileId
- ? (configsByProfile[activeProfileId]?.[addon.id] ?? DEFAULT_ADDON_CONFIG)
+ ? (configsByProfile[activeProfileId]?.[addon.id])
: undefined;
return config?.isActive && config?.useCatalogsInSearch;
})
@@ -382,7 +382,7 @@ export function useMeta(type: ContentType, id: string, enabled: boolean = true)
// Find all activated addons that support this content
const compatibleAddons = addons.filter((addon) => {
const config = activeProfileId
- ? (configsByProfile[activeProfileId]?.[addon.id] ?? DEFAULT_ADDON_CONFIG)
+ ? (configsByProfile[activeProfileId]?.[addon.id])
: undefined;
return config?.isActive && addonSupportsContent(addon, type, id);
});
@@ -466,7 +466,7 @@ export function useStreams(
// Find all activated addons that support this content and have stream resource
const compatibleAddons = addons.filter((addon) => {
const config = activeProfileId
- ? (configsByProfile[activeProfileId]?.[addon.id] ?? DEFAULT_ADDON_CONFIG)
+ ? (configsByProfile[activeProfileId]?.[addon.id])
: undefined;
if (!config?.isActive) return false;
@@ -573,7 +573,7 @@ export function useSubtitles(
const compatibleAddons = useMemo(() => {
return Object.values(addons).filter((addon) => {
const config = activeProfileId
- ? (configsByProfile[activeProfileId]?.[addon.id] ?? DEFAULT_ADDON_CONFIG)
+ ? (configsByProfile[activeProfileId]?.[addon.id])
: undefined;
const { manifest } = addon;
diff --git a/src/components/basic/AppLogo.tsx b/src/components/basic/AppLogo.tsx
index d4cc1dd..146ed30 100644
--- a/src/components/basic/AppLogo.tsx
+++ b/src/components/basic/AppLogo.tsx
@@ -1,7 +1,8 @@
-import { useTheme } from '@shopify/restyle';
-import { type Theme } from '@/theme/theme';
import { memo } from 'react';
+import { useTheme } from '@shopify/restyle';
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
+import { type Theme } from '@/theme/theme';
+import { LOGO_PATH_D } from '@/constants/logo-path';
interface AppLogoProps {
size: number;
@@ -10,9 +11,6 @@ interface AppLogoProps {
const GRADIENT_ID = 'appLogoGradient';
-const LOGO_PATH_D =
- 'M567.000 188.960 C 538.772 192.590,515.586 203.708,495.150 223.413 C 477.029 240.886,465.839 260.642,459.229 286.833 C 456.809 296.422,456.539 299.268,456.558 315.000 C 456.575 329.360,456.976 334.283,458.795 342.436 C 462.753 360.182,471.354 383.847,481.436 404.734 C 492.848 428.378,496.938 439.773,497.685 450.000 C 498.425 460.139,496.939 465.114,491.564 470.489 C 485.255 476.797,480.417 477.664,460.245 476.099 C 411.154 472.289,364.456 489.176,329.832 523.258 C 315.385 537.479,305.983 550.203,297.311 567.267 C 292.269 577.188,290.879 577.116,293.482 567.069 C 295.719 558.438,301.642 545.186,308.493 533.487 C 318.187 516.935,319.395 514.166,319.817 507.534 C 320.522 496.455,314.217 487.693,301.335 481.852 C 290.725 477.042,283.127 475.722,269.500 476.323 C 256.556 476.893,252.395 477.909,240.519 483.396 C 220.162 492.801,207.665 506.616,200.369 527.779 C 197.291 536.708,197.018 538.478,197.025 549.500 C 197.034 565.853,200.166 578.000,204.375 578.000 C 205.106 578.000,207.171 574.737,208.964 570.750 C 216.567 553.840,220.469 547.519,226.879 541.728 C 233.880 535.403,244.610 531.001,256.750 529.473 C 262.965 528.691,263.865 530.241,258.250 532.059 C 247.494 535.541,237.061 543.537,231.096 552.868 C 222.821 565.815,220.583 583.382,225.317 598.244 C 230.008 612.969,237.736 621.582,253.500 629.652 C 271.776 639.008,273.332 641.351,274.987 662.000 C 276.394 679.570,278.082 688.855,282.541 703.565 C 286.592 716.931,291.957 729.384,298.720 741.122 C 303.947 750.194,316.767 767.514,321.839 772.356 L 324.832 775.213 321.379 764.356 C 315.932 747.226,314.542 737.846,314.584 718.500 C 314.613 704.895,315.056 699.482,316.799 691.396 C 319.749 677.719,323.268 667.310,328.710 656.171 C 339.810 633.448,353.646 618.139,379.000 600.523 C 384.775 596.510,394.214 589.100,399.975 584.056 C 412.304 573.261,421.347 567.113,432.774 561.759 C 440.717 558.037,459.758 552.425,460.826 553.492 C 461.456 554.122,462.107 553.684,452.000 559.425 C 440.023 566.228,432.588 571.681,424.462 579.622 C 415.701 588.184,410.949 594.587,402.995 608.549 C 389.300 632.588,387.332 635.740,382.111 642.000 C 379.129 645.575,372.774 652.550,367.988 657.500 C 358.760 667.044,356.328 671.604,357.404 677.341 C 359.495 688.486,382.465 701.513,411.929 708.262 C 424.888 711.231,454.055 711.452,467.000 708.679 C 493.907 702.917,516.083 690.880,534.205 672.203 C 539.543 666.701,546.013 658.951,548.582 654.979 C 558.548 639.573,566.550 618.677,568.869 602.000 L 569.634 596.500 570.882 600.500 C 571.568 602.700,572.374 610.350,572.673 617.500 C 574.123 652.206,563.676 686.503,543.281 714.000 C 535.977 723.847,518.757 740.824,507.573 749.203 C 502.664 752.881,490.994 760.078,481.641 765.195 C 461.168 776.397,452.490 783.879,449.522 792.888 C 445.072 806.398,452.032 822.024,465.269 828.240 C 477.236 833.860,493.400 835.988,524.000 835.972 C 565.461 835.950,592.767 829.536,629.000 811.308 C 649.495 800.996,666.310 789.289,681.684 774.625 C 701.639 755.593,714.818 738.230,725.612 716.753 C 736.550 694.990,741.820 678.958,745.921 654.964 C 752.530 616.300,747.098 575.997,730.186 538.219 C 719.333 513.976,707.095 497.152,683.673 474.278 C 652.483 443.817,651.413 442.659,649.647 437.478 C 643.719 420.080,655.503 405.980,678.339 403.145 C 689.350 401.778,704.406 403.281,723.000 407.605 C 738.311 411.165,740.617 411.443,755.000 411.471 C 766.911 411.493,772.237 411.065,778.000 409.622 C 782.125 408.589,785.906 407.373,786.403 406.919 C 787.650 405.780,781.821 398.441,775.453 393.134 C 760.041 380.289,738.772 373.529,696.372 367.998 C 658.469 363.055,643.678 359.157,627.937 349.963 C 620.553 345.651,609.000 335.610,609.000 333.504 C 609.000 332.739,612.114 331.456,616.750 330.312 C 621.013 329.261,624.450 327.973,624.389 327.450 C 623.882 323.111,604.928 322.016,596.500 325.839 C 587.056 330.123,581.663 337.646,581.857 346.266 C 582.032 354.036,583.233 354.984,586.291 349.767 C 589.091 344.988,596.208 338.000,598.275 338.000 C 598.973 338.000,603.584 341.938,608.522 346.750 C 618.105 356.089,624.165 360.130,636.753 365.572 C 651.688 372.029,677.671 377.559,704.500 379.990 C 711.100 380.588,720.088 381.785,724.473 382.650 C 738.240 385.365,762.000 394.462,762.000 397.017 C 762.000 398.491,744.929 398.205,734.000 396.548 C 728.775 395.755,720.682 394.191,716.015 393.071 C 693.679 387.713,671.981 388.181,641.000 394.688 C 614.320 400.291,606.761 401.258,594.500 400.634 C 581.481 399.971,575.125 398.388,565.000 393.288 C 551.486 386.480,540.139 372.648,536.999 359.156 C 535.146 351.192,535.530 339.572,537.896 332.000 C 540.273 324.396,543.797 318.588,550.583 311.085 L 555.482 305.670 549.738 306.263 C 541.474 307.116,533.885 310.989,526.310 318.220 L 519.731 324.500 520.289 313.500 C 521.265 294.285,526.827 279.315,538.984 263.182 C 548.383 250.709,560.167 240.963,574.717 233.629 C 580.879 230.523,582.511 230.311,581.460 232.750 C 579.992 236.160,578.195 246.030,578.695 247.940 C 579.106 249.515,580.283 248.516,584.983 242.603 C 604.419 218.144,629.711 213.227,648.538 230.249 C 654.979 236.072,658.044 241.832,661.966 255.480 C 666.654 271.796,671.587 278.961,683.808 287.208 C 696.460 295.745,715.692 302.259,734.790 304.476 C 738.205 304.872,740.994 305.490,740.988 305.848 C 740.981 306.207,739.421 307.507,737.521 308.737 C 733.370 311.426,727.031 319.238,724.118 325.256 C 719.583 334.625,719.139 346.757,723.005 355.666 C 725.288 360.926,727.456 362.247,738.055 364.833 C 756.404 369.310,772.917 378.221,784.535 389.914 C 792.010 397.437,796.370 404.528,800.391 415.701 C 802.101 420.452,804.108 424.541,804.852 424.787 C 806.984 425.493,816.671 414.478,819.902 407.675 C 830.761 384.814,831.827 362.385,823.091 340.569 C 815.585 321.823,802.449 308.110,783.840 299.594 C 773.600 294.908,765.511 293.131,746.745 291.443 C 722.230 289.238,713.511 287.222,700.021 280.638 C 690.590 276.035,681.550 267.958,677.966 260.933 C 676.801 258.650,674.050 251.544,671.852 245.141 C 667.052 231.161,664.724 226.976,657.264 218.919 C 644.890 205.554,623.514 194.496,602.121 190.393 C 592.559 188.559,575.524 187.864,567.000 188.960 M603.500 252.871 C 598.637 255.092,593.547 260.123,590.722 265.500 C 588.005 270.674,587.794 281.948,590.326 286.737 C 594.923 295.430,602.454 300.781,611.378 301.696 C 634.599 304.074,647.987 276.302,632.109 258.690 C 630.124 256.489,626.700 253.868,624.500 252.866 C 619.149 250.429,608.841 250.431,603.500 252.871 M621.401 266.394 C 628.135 269.208,629.897 275.592,625.411 280.923 C 623.323 283.404,622.056 284.000,618.866 284.000 C 607.621 284.000,606.680 267.558,617.782 265.063 C 617.937 265.029,619.566 265.627,621.401 266.394 M673.923 308.923 C 672.205 310.641,671.000 312.908,671.000 314.423 C 671.000 316.953,671.132 317.000,678.250 317.024 C 682.237 317.037,687.188 317.440,689.250 317.919 C 693.646 318.939,693.910 318.208,690.899 313.337 C 688.695 309.770,682.561 306.000,678.963 306.000 C 677.799 306.000,675.531 307.315,673.923 308.923';
-
export const AppLogo = memo(({ size, color }: AppLogoProps) => {
const theme = useTheme();
diff --git a/src/components/settings/AddonsSettingsContent.tsx b/src/components/settings/AddonsSettingsContent.tsx
index b78edaa..35a55e2 100644
--- a/src/components/settings/AddonsSettingsContent.tsx
+++ b/src/components/settings/AddonsSettingsContent.tsx
@@ -16,6 +16,11 @@ import { InstalledAddon } from '@/types/stremio';
import { showToast } from '@/store/toast.store';
import { SettingsSwitch } from '@/components/settings/SettingsSwitch';
import { Focusable } from '@/components/basic/Focusable';
+import { RemoteControlModal } from '@/components/settings/RemoteControlModal';
+import { useResponsiveLayout } from '@/hooks/useBreakpoint';
+import { createDebugLogger } from '@/utils/debug';
+
+const debug = createDebugLogger('AddonsSettings');
export interface AddonsSettingsContentProps {
/** Whether to show the install addon section (default: true) */
@@ -34,6 +39,8 @@ export const AddonsSettingsContent: FC = memo(
({ showInstall = true, showInstalled = true, scrollable = true }) => {
const { t } = useTranslation(['settings', 'common']);
const [manifestUrl, setManifestUrl] = useState('');
+ const [isRemoteControlVisible, setIsRemoteControlVisible] = useState(false);
+ const { isWide } = useResponsiveLayout();
const theme = useTheme();
const activeProfileId = useProfileStore((state) => state.activeProfileId);
const { configsByProfile, orderedAddons, storeError, reorderAddon } = useAddonStore(
@@ -142,8 +149,20 @@ export const AddonsSettingsContent: FC = memo(
[activeAddons, orderedAddons, reorderAddon, activeProfileId]
);
+ const handleOpenRemoteControl = useCallback(() => {
+ setIsRemoteControlVisible(true);
+ }, []);
+
+ const handleCloseRemoteControl = useCallback(() => {
+ setIsRemoteControlVisible(false);
+ }, []);
+
const content = (
+ {(isWide || __DEV__) && (
+
+ )}
+
{/* Install Addon Section */}
{showInstall && (
@@ -230,13 +249,72 @@ export const AddonsSettingsContent: FC = memo(
);
if (scrollable) {
- return {content};
+ return (
+ <>
+ {content}
+
+ >
+ );
}
- return content;
+ return (
+ <>
+ {content}
+
+ >
+ );
}
);
+interface RemoteControlShortcutProps {
+ onPress: () => void;
+}
+
+/**
+ * Shortcut card that opens the remote control modal.
+ * Shown on wide layouts and in dev mode.
+ */
+const RemoteControlShortcut: FC = memo(({ onPress }) => {
+ const theme = useTheme();
+ const { t } = useTranslation('settings');
+
+ return (
+
+ {({ isFocused }) => (
+
+
+
+
+ {t('remoteControl.manage_title')}
+
+
+ {t('remoteControl.manage_desc')}
+
+
+
+
+ )}
+
+ );
+});
+
interface AddonCardProps {
addon: InstalledAddon;
onRemove: (id: string, name: string) => void;
@@ -283,12 +361,20 @@ const AddonCard: FC = memo(
}, [addon.manifestUrl, onConfigure]);
const handleToggleActive = useCallback(() => {
+ debug('AddonCard.handleToggleActive', {
+ addonId: addon.id,
+ addonName: addon.manifest.name,
+ activeProfileId,
+ currentIsActive: isActive,
+ action: isActive ? 'deactivate' : 'activate',
+ hasConfig: !!config,
+ });
if (isActive) {
deactivateAddon(addon.id);
} else {
activateAddon(addon.id);
}
- }, [isActive, addon.id, activateAddon, deactivateAddon]);
+ }, [isActive, addon.id, activateAddon, deactivateAddon, activeProfileId, config, addon.manifest.name]);
const handleToggleHome = useCallback(() => {
toggleUseCatalogsOnHome(addon.id);
diff --git a/src/components/settings/RemoteControlContent.tsx b/src/components/settings/RemoteControlContent.tsx
new file mode 100644
index 0000000..9db328c
--- /dev/null
+++ b/src/components/settings/RemoteControlContent.tsx
@@ -0,0 +1,189 @@
+import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react';
+import { ActivityIndicator, Platform, ScrollView } from 'react-native';
+import * as Network from 'expo-network';
+import QRCode from 'react-native-qrcode-svg';
+import { Button } from '@/components/basic/Button';
+import { SettingsCard } from '@/components/settings/SettingsCard';
+import { useLocalServerStore } from '@/store/local-server.store';
+import { showToast } from '@/store/toast.store';
+import { createDebugLogger } from '@/utils/debug';
+import { Box, Text } from '@/theme/theme';
+import { useTranslation } from 'react-i18next';
+
+const debug = createDebugLogger('RemoteControl');
+
+export const RemoteControlContent: FC<{ onStop?: () => void }> = memo(({ onStop }) => {
+ const isRunning = useLocalServerStore((state) => state.isRunning);
+ const { t } = useTranslation('settings');
+ const pin = useLocalServerStore((state) => state.pin);
+ const port = useLocalServerStore((state) => state.port);
+ const startServer = useLocalServerStore((state) => state.startServer);
+ const stopServer = useLocalServerStore((state) => state.stopServer);
+ const generatePin = useLocalServerStore((state) => state.generatePin);
+
+ const [localIp, setLocalIp] = useState('');
+ const [ipDetectionFailed, setIpDetectionFailed] = useState(false);
+
+ const fetchLocalIp = useCallback(async () => {
+ try {
+ const ip = await Promise.race([
+ Network.getIpAddressAsync(),
+ new Promise((resolve) => setTimeout(() => resolve(null), 5_000)),
+ ]);
+ if (!ip || ip === '0.0.0.0' || ip === '127.0.0.1') {
+ debug('fetchLocalIp: unusable IP', ip);
+ setIpDetectionFailed(true);
+ setLocalIp('');
+ return;
+ }
+ setLocalIp(ip);
+ setIpDetectionFailed(false);
+ } catch (err) {
+ debug('fetchLocalIp: error', err);
+ setIpDetectionFailed(true);
+ setLocalIp('');
+ }
+ }, []);
+
+ const serverUrl = useMemo(() => {
+ if (!localIp) return '';
+ return `http://${localIp}:${port}`;
+ }, [localIp, port]);
+
+ const ipHint = useMemo(() => {
+ if (Platform.OS === 'ios') return t('remoteControl.ip_hint_ios');
+ return t('remoteControl.ip_hint_android');
+ }, [t]);
+
+ const qrUrl = useMemo(() => {
+ if (!serverUrl) return '';
+ return `${serverUrl}?pin=${pin}`;
+ }, [serverUrl, pin]);
+
+ const handleRegeneratePin = useCallback(() => {
+ generatePin();
+ showToast({
+ title: t('remoteControl.toast.pin_updated_title'),
+ message: t('remoteControl.toast.pin_updated_msg'),
+ });
+ }, [generatePin, t]);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ debug('mounting: starting server');
+ void (async () => {
+ try {
+ await startServer();
+ if (cancelled) {
+ // Server started but component already unmounting â stop it.
+ debug('startServer completed after unmount, stopping');
+ void stopServer();
+ }
+ } catch (err) {
+ if (!cancelled) {
+ debug('startServer failed', err);
+ showToast({
+ title: t('remoteControl.toast.server_failed_title'),
+ message: t('remoteControl.toast.server_failed_msg'),
+ preset: 'error',
+ });
+ }
+ }
+ })();
+
+ return () => {
+ debug('unmounting: stopping server');
+ cancelled = true;
+ void stopServer();
+ };
+ }, [startServer, stopServer, t]);
+
+ const handleStop = useCallback(() => {
+ void stopServer();
+ onStop?.();
+ }, [stopServer, onStop]);
+
+ useEffect(() => {
+ if (!isRunning) return;
+ void fetchLocalIp();
+ }, [fetchLocalIp, isRunning]);
+
+ return (
+
+
+
+ {t('remoteControl.description')}
+
+
+ {!isRunning && (
+
+
+
+
+ {t('remoteControl.starting')}
+
+
+
+ )}
+
+ {isRunning && (
+
+
+ {t('remoteControl.url')}
+
+ {serverUrl ||
+ (ipDetectionFailed
+ ? t('remoteControl.address_unavailable')
+ : t('remoteControl.loading_address'))}
+
+ {ipDetectionFailed && !serverUrl && (
+
+
+ {t('remoteControl.ip_fallback', { port })}
+
+
+ {ipHint}
+
+
+ )}
+
+
+
+ {t('remoteControl.pin')}
+
+ {pin}
+
+
+
+ {!!qrUrl && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ {t('remoteControl.footer')}
+
+
+
+ );
+});
+
+RemoteControlContent.displayName = 'RemoteControlContent';
diff --git a/src/components/settings/RemoteControlModal.tsx b/src/components/settings/RemoteControlModal.tsx
new file mode 100644
index 0000000..17ce455
--- /dev/null
+++ b/src/components/settings/RemoteControlModal.tsx
@@ -0,0 +1,22 @@
+import { FC, memo } from 'react';
+import { Modal } from '@/components/basic/Modal';
+import { RemoteControlContent } from '@/components/settings/RemoteControlContent';
+import { useTranslation } from 'react-i18next';
+
+interface RemoteControlModalProps {
+ visible: boolean;
+ onClose: () => void;
+}
+
+export const RemoteControlModal: FC = memo(
+ ({ visible, onClose }) => {
+ const { t } = useTranslation('settings');
+ return (
+
+ {visible && }
+
+ );
+ }
+);
+
+RemoteControlModal.displayName = 'RemoteControlModal';
\ No newline at end of file
diff --git a/src/constants/logo-path.ts b/src/constants/logo-path.ts
new file mode 100644
index 0000000..5f1295d
--- /dev/null
+++ b/src/constants/logo-path.ts
@@ -0,0 +1,3 @@
+/** SVG path data for the DodoStream logo (1024Ă1024 viewBox). */
+export const LOGO_PATH_D =
+ 'M567.000 188.960 C 538.772 192.590,515.586 203.708,495.150 223.413 C 477.029 240.886,465.839 260.642,459.229 286.833 C 456.809 296.422,456.539 299.268,456.558 315.000 C 456.575 329.360,456.976 334.283,458.795 342.436 C 462.753 360.182,471.354 383.847,481.436 404.734 C 492.848 428.378,496.938 439.773,497.685 450.000 C 498.425 460.139,496.939 465.114,491.564 470.489 C 485.255 476.797,480.417 477.664,460.245 476.099 C 411.154 472.289,364.456 489.176,329.832 523.258 C 315.385 537.479,305.983 550.203,297.311 567.267 C 292.269 577.188,290.879 577.116,293.482 567.069 C 295.719 558.438,301.642 545.186,308.493 533.487 C 318.187 516.935,319.395 514.166,319.817 507.534 C 320.522 496.455,314.217 487.693,301.335 481.852 C 290.725 477.042,283.127 475.722,269.500 476.323 C 256.556 476.893,252.395 477.909,240.519 483.396 C 220.162 492.801,207.665 506.616,200.369 527.779 C 197.291 536.708,197.018 538.478,197.025 549.500 C 197.034 565.853,200.166 578.000,204.375 578.000 C 205.106 578.000,207.171 574.737,208.964 570.750 C 216.567 553.840,220.469 547.519,226.879 541.728 C 233.880 535.403,244.610 531.001,256.750 529.473 C 262.965 528.691,263.865 530.241,258.250 532.059 C 247.494 535.541,237.061 543.537,231.096 552.868 C 222.821 565.815,220.583 583.382,225.317 598.244 C 230.008 612.969,237.736 621.582,253.500 629.652 C 271.776 639.008,273.332 641.351,274.987 662.000 C 276.394 679.570,278.082 688.855,282.541 703.565 C 286.592 716.931,291.957 729.384,298.720 741.122 C 303.947 750.194,316.767 767.514,321.839 772.356 L 324.832 775.213 321.379 764.356 C 315.932 747.226,314.542 737.846,314.584 718.500 C 314.613 704.895,315.056 699.482,316.799 691.396 C 319.749 677.719,323.268 667.310,328.710 656.171 C 339.810 633.448,353.646 618.139,379.000 600.523 C 384.775 596.510,394.214 589.100,399.975 584.056 C 412.304 573.261,421.347 567.113,432.774 561.759 C 440.717 558.037,459.758 552.425,460.826 553.492 C 461.456 554.122,462.107 553.684,452.000 559.425 C 440.023 566.228,432.588 571.681,424.462 579.622 C 415.701 588.184,410.949 594.587,402.995 608.549 C 389.300 632.588,387.332 635.740,382.111 642.000 C 379.129 645.575,372.774 652.550,367.988 657.500 C 358.760 667.044,356.328 671.604,357.404 677.341 C 359.495 688.486,382.465 701.513,411.929 708.262 C 424.888 711.231,454.055 711.452,467.000 708.679 C 493.907 702.917,516.083 690.880,534.205 672.203 C 539.543 666.701,546.013 658.951,548.582 654.979 C 558.548 639.573,566.550 618.677,568.869 602.000 L 569.634 596.500 570.882 600.500 C 571.568 602.700,572.374 610.350,572.673 617.500 C 574.123 652.206,563.676 686.503,543.281 714.000 C 535.977 723.847,518.757 740.824,507.573 749.203 C 502.664 752.881,490.994 760.078,481.641 765.195 C 461.168 776.397,452.490 783.879,449.522 792.888 C 445.072 806.398,452.032 822.024,465.269 828.240 C 477.236 833.860,493.400 835.988,524.000 835.972 C 565.461 835.950,592.767 829.536,629.000 811.308 C 649.495 800.996,666.310 789.289,681.684 774.625 C 701.639 755.593,714.818 738.230,725.612 716.753 C 736.550 694.990,741.820 678.958,745.921 654.964 C 752.530 616.300,747.098 575.997,730.186 538.219 C 719.333 513.976,707.095 497.152,683.673 474.278 C 652.483 443.817,651.413 442.659,649.647 437.478 C 643.719 420.080,655.503 405.980,678.339 403.145 C 689.350 401.778,704.406 403.281,723.000 407.605 C 738.311 411.165,740.617 411.443,755.000 411.471 C 766.911 411.493,772.237 411.065,778.000 409.622 C 782.125 408.589,785.906 407.373,786.403 406.919 C 787.650 405.780,781.821 398.441,775.453 393.134 C 760.041 380.289,738.772 373.529,696.372 367.998 C 658.469 363.055,643.678 359.157,627.937 349.963 C 620.553 345.651,609.000 335.610,609.000 333.504 C 609.000 332.739,612.114 331.456,616.750 330.312 C 621.013 329.261,624.450 327.973,624.389 327.450 C 623.882 323.111,604.928 322.016,596.500 325.839 C 587.056 330.123,581.663 337.646,581.857 346.266 C 582.032 354.036,583.233 354.984,586.291 349.767 C 589.091 344.988,596.208 338.000,598.275 338.000 C 598.973 338.000,603.584 341.938,608.522 346.750 C 618.105 356.089,624.165 360.130,636.753 365.572 C 651.688 372.029,677.671 377.559,704.500 379.990 C 711.100 380.588,720.088 381.785,724.473 382.650 C 738.240 385.365,762.000 394.462,762.000 397.017 C 762.000 398.491,744.929 398.205,734.000 396.548 C 728.775 395.755,720.682 394.191,716.015 393.071 C 693.679 387.713,671.981 388.181,641.000 394.688 C 614.320 400.291,606.761 401.258,594.500 400.634 C 581.481 399.971,575.125 398.388,565.000 393.288 C 551.486 386.480,540.139 372.648,536.999 359.156 C 535.146 351.192,535.530 339.572,537.896 332.000 C 540.273 324.396,543.797 318.588,550.583 311.085 L 555.482 305.670 549.738 306.263 C 541.474 307.116,533.885 310.989,526.310 318.220 L 519.731 324.500 520.289 313.500 C 521.265 294.285,526.827 279.315,538.984 263.182 C 548.383 250.709,560.167 240.963,574.717 233.629 C 580.879 230.523,582.511 230.311,581.460 232.750 C 579.992 236.160,578.195 246.030,578.695 247.940 C 579.106 249.515,580.283 248.516,584.983 242.603 C 604.419 218.144,629.711 213.227,648.538 230.249 C 654.979 236.072,658.044 241.832,661.966 255.480 C 666.654 271.796,671.587 278.961,683.808 287.208 C 696.460 295.745,715.692 302.259,734.790 304.476 C 738.205 304.872,740.994 305.490,740.988 305.848 C 740.981 306.207,739.421 307.507,737.521 308.737 C 733.370 311.426,727.031 319.238,724.118 325.256 C 719.583 334.625,719.139 346.757,723.005 355.666 C 725.288 360.926,727.456 362.247,738.055 364.833 C 756.404 369.310,772.917 378.221,784.535 389.914 C 792.010 397.437,796.370 404.528,800.391 415.701 C 802.101 420.452,804.108 424.541,804.852 424.787 C 806.984 425.493,816.671 414.478,819.902 407.675 C 830.761 384.814,831.827 362.385,823.091 340.569 C 815.585 321.823,802.449 308.110,783.840 299.594 C 773.600 294.908,765.511 293.131,746.745 291.443 C 722.230 289.238,713.511 287.222,700.021 280.638 C 690.590 276.035,681.550 267.958,677.966 260.933 C 676.801 258.650,674.050 251.544,671.852 245.141 C 667.052 231.161,664.724 226.976,657.264 218.919 C 644.890 205.554,623.514 194.496,602.121 190.393 C 592.559 188.559,575.524 187.864,567.000 188.960 M603.500 252.871 C 598.637 255.092,593.547 260.123,590.722 265.500 C 588.005 270.674,587.794 281.948,590.326 286.737 C 594.923 295.430,602.454 300.781,611.378 301.696 C 634.599 304.074,647.987 276.302,632.109 258.690 C 630.124 256.489,626.700 253.868,624.500 252.866 C 619.149 250.429,608.841 250.431,603.500 252.871 M621.401 266.394 C 628.135 269.208,629.897 275.592,625.411 280.923 C 623.323 283.404,622.056 284.000,618.866 284.000 C 607.621 284.000,606.680 267.558,617.782 265.063 C 617.937 265.029,619.566 265.627,621.401 266.394 M673.923 308.923 C 672.205 310.641,671.000 312.908,671.000 314.423 C 671.000 316.953,671.132 317.000,678.250 317.024 C 682.237 317.037,687.188 317.440,689.250 317.919 C 693.646 318.939,693.910 318.208,690.899 313.337 C 688.695 309.770,682.561 306.000,678.963 306.000 C 677.799 306.000,675.531 307.315,673.923 308.923';
diff --git a/src/i18n/de/settings.json b/src/i18n/de/settings.json
index f5543c9..41c98aa 100644
--- a/src/i18n/de/settings.json
+++ b/src/i18n/de/settings.json
@@ -274,5 +274,32 @@
"reset_simkl_confirm_msg": "Bist du sicher, dass du deine Simkl-Verbindung und Synchronisations-Cursor fĂŒr dieses Profil zurĂŒcksetzen möchtest? Du musst dein Simkl-Konto erneut verbinden.",
"simkl_reset": "Simkl-Synchronisation zurĂŒckgesetzt",
"profile_data_notice": "Diese Aktionen betreffen nur das aktuell aktive Profil. Lokale Einstellungen und andere Profile bleiben unverÀndert."
+ },
+ "remoteControl": {
+ "title": "Fernsteuerung",
+ "description": "Der Fernsteuerungsserver startet automatisch, damit du Addons von deinem Telefon oder Computer aus verwalten kannst.",
+ "starting": "Startet\u2026",
+ "url": "URL",
+ "loading_address": "Adresse wird geladen...",
+ "ip_fallback": "Der Server lĂ€uft auf Port {{port}}. Ăffne die Web-UI unter http://:{{port}}",
+ "address_unavailable": "Adresse nicht verfĂŒgbar",
+ "ip_hint_ios": "Finde die IP deines iPhones/iPads: Einstellungen > WLAN > (i) neben deinem Netzwerk > IP-Adresse.",
+ "ip_hint_android": "Finde die IP deines GerÀts: Einstellungen > Netzwerk & Internet > WLAN > dein Netzwerk > IP-Adresse.",
+ "pin": "PIN",
+ "regenerate_pin": "PIN neu generieren",
+ "stop": "Fernsteuerung stoppen",
+ "footer": "Der Server stoppt automatisch, wenn du dieses Panel schlieĂt.",
+ "manage_title": "Addons remote verwalten",
+ "manage_desc": "Fernsteuerung öffnen, um vom Telefon aus zu synchronisieren",
+ "toast": {
+ "pin_updated_title": "PIN aktualisiert",
+ "pin_updated_msg": "Eine neue PIN wurde generiert.",
+ "no_network_title": "Keine Netzwerkadresse gefunden",
+ "no_network_msg": "Verbinde dich mit einem WLAN-Netzwerk und versuche es erneut.",
+ "ip_error_title": "IP-Adresse konnte nicht abgerufen werden",
+ "ip_error_msg": "Bitte ĂŒberprĂŒfe deine Netzwerkverbindung.",
+ "server_failed_title": "Server konnte nicht gestartet werden",
+ "server_failed_msg": "Bitte versuche es erneut."
+ }
}
}
diff --git a/src/i18n/en/settings.json b/src/i18n/en/settings.json
index cc3dfd4..fda5b75 100644
--- a/src/i18n/en/settings.json
+++ b/src/i18n/en/settings.json
@@ -274,5 +274,32 @@
"reset_simkl_confirm_msg": "Are you sure you want to reset your Simkl connection and sync cursors for this profile? You will need to reconnect your Simkl account.",
"simkl_reset": "Simkl Sync Reset",
"profile_data_notice": "These actions only affect the currently active profile. Local settings and other profiles will remain unchanged."
+ },
+ "remoteControl": {
+ "title": "Remote Control",
+ "description": "The remote control server starts automatically so you can manage addons from your phone or computer.",
+ "starting": "Starting\u2026",
+ "url": "URL",
+ "loading_address": "Loading address...",
+ "ip_fallback": "The server is running on port {{port}}. Open the web UI on http://:{{port}}",
+ "address_unavailable": "Address unavailable",
+ "ip_hint_ios": "Find your iPhone/iPad IP: Settings > Wi-Fi > tap (i) next to your network > IP Address.",
+ "ip_hint_android": "Find your device IP: Settings > Network & Internet > Wi-Fi > your network > IP Address.",
+ "pin": "PIN",
+ "regenerate_pin": "Regenerate PIN",
+ "stop": "Stop Remote Control",
+ "footer": "The server stops automatically when you close this panel.",
+ "manage_title": "Manage addons remotely",
+ "manage_desc": "Open Remote Control to sync from your phone",
+ "toast": {
+ "pin_updated_title": "PIN updated",
+ "pin_updated_msg": "A new PIN has been generated.",
+ "no_network_title": "No network address found",
+ "no_network_msg": "Connect to a Wi-Fi network and try again.",
+ "ip_error_title": "Unable to get IP",
+ "ip_error_msg": "Please check your network connection.",
+ "server_failed_title": "Server failed to start",
+ "server_failed_msg": "Please try again."
+ }
}
-}
+}
\ No newline at end of file
diff --git a/src/store/__tests__/addon.store.migration.test.ts b/src/store/__tests__/addon.store.migration.test.ts
index f186edf..f56a9a7 100644
--- a/src/store/__tests__/addon.store.migration.test.ts
+++ b/src/store/__tests__/addon.store.migration.test.ts
@@ -8,9 +8,8 @@
*
* Fix (0.9.1):
* - version bumped to 3 with a synchronous migrate
- * - configsByProfile lookup falls back to DEFAULT_ADDON_CONFIG so addons with
- * missing per-profile config entries (victims of the broken v2 migration) are
- * treated as active instead of invisible
+ * - addons without per-profile config entries are treated as inactive
+ * (pre-migration users must re-enable addons)
*/
interface AddonProfileConfig {
@@ -32,12 +31,6 @@ interface AddonState {
configsByProfile: Record>;
}
-const DEFAULT_ADDON_CONFIG: AddonProfileConfig = {
- isActive: true,
- useCatalogsOnHome: true,
- useCatalogsInSearch: true,
- useForSubtitles: true,
-};
/** Mirrors the synchronous migrate function in addon.store.ts (version 3). */
const migrateAddonStoreState = (
@@ -63,12 +56,12 @@ const migrateAddonStoreState = (
return state;
};
-/** Simulates the config lookup used in hooks.ts with the DEFAULT_ADDON_CONFIG fallback. */
+/** Simulates the config lookup used in hooks.ts (no fallback â missing config = undefined). */
const getConfig = (
configsByProfile: Record>,
profileId: string,
addonId: string
-): AddonProfileConfig => configsByProfile[profileId]?.[addonId] ?? DEFAULT_ADDON_CONFIG;
+): AddonProfileConfig | undefined => configsByProfile[profileId]?.[addonId];
describe('addon store migration', () => {
describe('v1 -> v3: strips legacy flat fields from addon objects', () => {
@@ -213,20 +206,16 @@ describe('addon store migration', () => {
});
});
- describe('DEFAULT_ADDON_CONFIG fallback (recovery for broken v2 migration victims)', () => {
- it('treats addon with no config entry as active with all defaults', () => {
- // Arrange â configsByProfile is empty (broken v2 migration result)
+ describe('config lookup without fallback (addons without config are inactive)', () => {
+ it('returns undefined when configsByProfile is empty', () => {
+ // Arrange â configsByProfile is empty
const configsByProfile: Record> = {};
// Act
const config = getConfig(configsByProfile, 'profileA', 'addonOne');
// Assert
- expect(config).toEqual(DEFAULT_ADDON_CONFIG);
- expect(config.isActive).toBe(true);
- expect(config.useCatalogsOnHome).toBe(true);
- expect(config.useCatalogsInSearch).toBe(true);
- expect(config.useForSubtitles).toBe(true);
+ expect(config).toBeUndefined();
});
it('returns the stored config when one exists', () => {
@@ -244,10 +233,10 @@ describe('addon store migration', () => {
// Assert â stored config wins (including isActive: false)
expect(config).toBe(storedConfig);
- expect(config.isActive).toBe(false);
+ expect(config!.isActive).toBe(false);
});
- it('falls back to DEFAULT_ADDON_CONFIG for a profile that has no entries at all', () => {
+ it('returns undefined for a profile that has no entry for this addon', () => {
// Arrange â profile exists in configsByProfile but has no entry for this addon
const configsByProfile: Record> = {
profileA: {},
@@ -257,7 +246,7 @@ describe('addon store migration', () => {
const config = getConfig(configsByProfile, 'profileA', 'addonOne');
// Assert
- expect(config).toEqual(DEFAULT_ADDON_CONFIG);
+ expect(config).toBeUndefined();
});
});
});
diff --git a/src/store/__tests__/local-server.store.test.ts b/src/store/__tests__/local-server.store.test.ts
new file mode 100644
index 0000000..0c5a55c
--- /dev/null
+++ b/src/store/__tests__/local-server.store.test.ts
@@ -0,0 +1,179 @@
+import { LOCAL_SERVER_PORT, startLocalServer, stopLocalServer } from '@/api/local-server/server';
+import { useLocalServerStore } from '@/store/local-server.store';
+
+jest.mock('@/api/local-server/server', () => ({
+ startLocalServer: jest.fn().mockResolvedValue(undefined),
+ stopLocalServer: jest.fn().mockResolvedValue(undefined),
+ LOCAL_SERVER_PORT: 7676,
+}));
+
+// Verify the mock stays in sync with the real constant.
+expect(LOCAL_SERVER_PORT).toBe(7676);
+
+const flushMicrotasks = async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+};
+
+describe('useLocalServerStore', () => {
+ beforeEach(() => {
+ useLocalServerStore.setState({ isRunning: false });
+ jest.clearAllMocks();
+ });
+
+ describe('initial state', () => {
+ it('has a 6-digit pin, isRunning false, and port 7676', () => {
+ // Arrange
+ const state = useLocalServerStore.getState();
+
+ // Assert
+ expect(state.pin).toMatch(/^\d{6}$/);
+ expect(state.isRunning).toBe(false);
+ expect(state.port).toBe(7676);
+ });
+ });
+
+ describe('generatePin', () => {
+ it('sets a new 6-digit pin and it differs from previous pin', () => {
+ // Arrange
+ const oldPin = useLocalServerStore.getState().pin;
+ const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.987654);
+
+ // Act
+ useLocalServerStore.getState().generatePin();
+ const newPin = useLocalServerStore.getState().pin;
+
+ // Assert
+ expect(newPin).toMatch(/^\d{6}$/);
+ expect(newPin).not.toBe(oldPin);
+
+ randomSpy.mockRestore();
+ });
+
+ it('when not running, does not call stopLocalServer or startLocalServer', () => {
+ // Arrange
+ useLocalServerStore.setState({ isRunning: false });
+
+ // Act
+ useLocalServerStore.getState().generatePin();
+
+ // Assert
+ expect(stopLocalServer).not.toHaveBeenCalled();
+ expect(startLocalServer).not.toHaveBeenCalled();
+ });
+
+ it('when running, calls stopLocalServer then startLocalServer with new pin', async () => {
+ // Arrange
+ useLocalServerStore.setState({ isRunning: true });
+ const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.444444);
+
+ // Act
+ useLocalServerStore.getState().generatePin();
+ const generatedPin = useLocalServerStore.getState().pin;
+ await flushMicrotasks();
+
+ // Assert
+ expect(stopLocalServer).toHaveBeenCalledTimes(1);
+ expect(startLocalServer).toHaveBeenCalledTimes(1);
+ expect(startLocalServer).toHaveBeenCalledWith(generatedPin);
+
+ const stopOrder = (stopLocalServer as jest.Mock).mock.invocationCallOrder[0];
+ const startOrder = (startLocalServer as jest.Mock).mock.invocationCallOrder[0];
+ expect(stopOrder).toBeLessThan(startOrder);
+
+ randomSpy.mockRestore();
+ });
+
+ it('when running and stop/restart fails, sets isRunning to false', async () => {
+ // Arrange
+ useLocalServerStore.setState({ isRunning: true });
+ (stopLocalServer as jest.Mock).mockRejectedValueOnce(new Error('stop failed'));
+
+ // Act
+ useLocalServerStore.getState().generatePin();
+ await flushMicrotasks();
+
+ // Assert
+ expect(useLocalServerStore.getState().isRunning).toBe(false);
+ });
+ });
+
+ describe('startServer', () => {
+ it('calls startLocalServer with current pin and sets isRunning to true', async () => {
+ // Arrange
+ const pin = useLocalServerStore.getState().pin;
+
+ // Act
+ await useLocalServerStore.getState().startServer();
+
+ // Assert
+ expect(startLocalServer).toHaveBeenCalledWith(pin);
+ expect(useLocalServerStore.getState().isRunning).toBe(true);
+ });
+
+ it('propagates error when startLocalServer throws and leaves isRunning false', async () => {
+ // Arrange
+ useLocalServerStore.setState({ isRunning: false });
+ const error = new Error('start failed');
+ (startLocalServer as jest.Mock).mockRejectedValueOnce(error);
+
+ // Act / Assert
+ await expect(useLocalServerStore.getState().startServer()).rejects.toThrow('start failed');
+ expect(useLocalServerStore.getState().isRunning).toBe(false);
+ });
+ });
+
+ describe('stopServer', () => {
+ it('calls stopLocalServer and sets isRunning to false', async () => {
+ // Arrange
+ useLocalServerStore.setState({ isRunning: true });
+
+ // Act
+ await useLocalServerStore.getState().stopServer();
+
+ // Assert
+ expect(stopLocalServer).toHaveBeenCalledTimes(1);
+ expect(useLocalServerStore.getState().isRunning).toBe(false);
+ });
+
+ it('resets isRunning even when stopLocalServer throws', async () => {
+ // Arrange
+ useLocalServerStore.setState({ isRunning: true });
+ const error = new Error('stop failed');
+ (stopLocalServer as jest.Mock).mockRejectedValueOnce(error);
+
+ // Act
+ await useLocalServerStore.getState().stopServer();
+
+ // Assert â stopServer is failsafe: always resets isRunning
+ expect(useLocalServerStore.getState().isRunning).toBe(false);
+ });
+ });
+
+ describe('persistence', () => {
+ it('partialize persists only pin (not isRunning, not port)', () => {
+ // Arrange
+ const state = useLocalServerStore.getState();
+
+ // Act
+ const partialized = useLocalServerStore.persist.getOptions().partialize?.(state);
+
+ // Assert
+ expect(partialized).toEqual({ pin: state.pin });
+ expect(partialized).not.toHaveProperty('isRunning');
+ expect(partialized).not.toHaveProperty('port');
+ });
+
+ it('isRunning can be reset independently and is not persisted state', async () => {
+ // Arrange
+ await useLocalServerStore.getState().startServer();
+ expect(useLocalServerStore.getState().isRunning).toBe(true);
+
+ // Act
+ useLocalServerStore.setState({ isRunning: false });
+
+ // Assert
+ expect(useLocalServerStore.getState().isRunning).toBe(false);
+ });
+ });
+});
diff --git a/src/store/addon.store.ts b/src/store/addon.store.ts
index a40cf23..f8dda33 100644
--- a/src/store/addon.store.ts
+++ b/src/store/addon.store.ts
@@ -2,19 +2,17 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { InstalledAddon, Manifest } from '@/types/stremio';
+import { type AddonConfig } from '@/types/addon-config';
import { useProfileStore } from '@/store/profile.store';
import { moveItem } from '@/utils/array';
+import { createDebugLogger } from '@/utils/debug';
-export interface AddonProfileConfig {
- isActive: boolean;
- useCatalogsOnHome: boolean;
- useCatalogsInSearch: boolean;
- useForSubtitles: boolean;
-}
+const debug = createDebugLogger('AddonStore');
+let initializing = false;
interface AddonState {
addons: Record;
- configsByProfile: Record>;
+ configsByProfile: Record>;
/** Per-profile ordered list of addon IDs. Determines display/catalog order. */
addonOrderByProfile: Record;
isLoading: boolean;
@@ -31,9 +29,10 @@ interface AddonState {
toggleUseCatalogsOnHome: (id: string, profileId?: string) => void;
toggleUseCatalogsInSearch: (id: string, profileId?: string) => void;
toggleUseForSubtitles: (id: string, profileId?: string) => void;
+ setAddonConfig: (id: string, config: Partial, profileId?: string) => void;
hasAddons: () => boolean;
hasAddon: (id: string) => boolean;
- getAddonConfig: (id: string, profileId?: string) => AddonProfileConfig | undefined;
+ getAddonConfig: (id: string, profileId?: string) => AddonConfig | undefined;
getAddonsList: () => InstalledAddon[];
getOrderedAddonsList: (profileId?: string) => InstalledAddon[];
setLoading: (isLoading: boolean) => void;
@@ -41,131 +40,31 @@ interface AddonState {
setError: (error: string | null) => void;
}
-export const DEFAULT_ADDON_CONFIG: AddonProfileConfig = {
- isActive: true,
- useCatalogsOnHome: true,
- useCatalogsInSearch: true,
- useForSubtitles: true,
-};
export const useAddonStore = create()(
persist(
- (set, get) => ({
- // Initial state
- addons: {},
- configsByProfile: {},
- addonOrderByProfile: {},
- isLoading: false,
- isInitialized: false,
- error: null,
-
- addAddon: (id: string, manifestUrl: string, manifest: Manifest) => {
- const { addons, configsByProfile, addonOrderByProfile, hasAddon } = get();
- const activeProfileId = useProfileStore.getState().activeProfileId;
-
- // Prevent duplicates
- if (hasAddon(id)) {
- set({ error: 'Addon already installed' });
+ (set, get) => {
+ const toggleConfigField = (
+ id: string,
+ field: keyof Omit,
+ profileId?: string,
+ ) => {
+ const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
+ debug('toggleConfigField', { id, field, targetProfileId, profileIdArg: profileId });
+ if (!targetProfileId) {
+ debug('toggleConfigField: no targetProfileId, aborting');
return;
}
-
- const newAddon: InstalledAddon = {
- id,
- manifestUrl,
- manifest,
- installedAt: Date.now(),
- };
-
- const newConfigs = { ...configsByProfile };
- const newOrder = { ...addonOrderByProfile };
- if (activeProfileId) {
- if (!newConfigs[activeProfileId]) {
- newConfigs[activeProfileId] = {};
- }
- newConfigs[activeProfileId] = {
- ...newConfigs[activeProfileId],
- [id]: { ...DEFAULT_ADDON_CONFIG },
- };
- // Append to end of order for this profile
- newOrder[activeProfileId] = [...(newOrder[activeProfileId] ?? []), id];
- }
-
- set({
- addons: { ...addons, [id]: newAddon },
- configsByProfile: newConfigs,
- addonOrderByProfile: newOrder,
- error: null,
- });
- },
-
- removeAddon: (id: string) => {
- set((state) => {
- const { [id]: removed, ...rest } = state.addons;
-
- // Remove config from all profiles
- const newConfigs = { ...state.configsByProfile };
- for (const profileId of Object.keys(newConfigs)) {
- if (newConfigs[profileId] && newConfigs[profileId][id]) {
- const { [id]: _, ...restConfigs } = newConfigs[profileId];
- newConfigs[profileId] = restConfigs;
- }
- }
-
- // Remove from order in all profiles
- const newOrder = { ...state.addonOrderByProfile };
- for (const profileId of Object.keys(newOrder)) {
- newOrder[profileId] = newOrder[profileId].filter((addonId) => addonId !== id);
- }
-
- return {
- addons: rest,
- configsByProfile: newConfigs,
- addonOrderByProfile: newOrder,
- error: null,
- };
- });
- },
-
- activateAddon: (id: string, profileId?: string) => {
- const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
- if (!targetProfileId) return;
-
- set((state) => {
- const profileConfigs = state.configsByProfile[targetProfileId] || {};
- const existingConfig = profileConfigs[id];
-
- // Append to end of order if not already tracked
- const currentOrder = state.addonOrderByProfile[targetProfileId] ?? [];
- const newOrder = currentOrder.includes(id) ? currentOrder : [...currentOrder, id];
-
- return {
- configsByProfile: {
- ...state.configsByProfile,
- [targetProfileId]: {
- ...profileConfigs,
- [id]: {
- ...DEFAULT_ADDON_CONFIG,
- ...existingConfig,
- isActive: true,
- },
- },
- },
- addonOrderByProfile: {
- ...state.addonOrderByProfile,
- [targetProfileId]: newOrder,
- },
- };
- });
- },
-
- deactivateAddon: (id: string, profileId?: string) => {
- const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
- if (!targetProfileId) return;
-
set((state) => {
const profileConfigs = state.configsByProfile[targetProfileId];
- if (!profileConfigs || !profileConfigs[id]) return state;
-
+ if (!profileConfigs || !profileConfigs[id]) {
+ debug('toggleConfigField: no config entry for addon', {
+ targetProfileId, id, hasProfileConfigs: !!profileConfigs,
+ });
+ return state;
+ }
+ const oldValue = profileConfigs[id][field];
+ debug('toggleConfigField: toggling', { id, field, from: oldValue, to: !oldValue });
return {
configsByProfile: {
...state.configsByProfile,
@@ -173,180 +72,310 @@ export const useAddonStore = create()(
...profileConfigs,
[id]: {
...profileConfigs[id],
- isActive: false,
+ [field]: !oldValue,
},
},
},
};
});
- },
+ };
+
+ return {
+ // Initial state
+ addons: {},
+ configsByProfile: {},
+ addonOrderByProfile: {},
+ isLoading: false,
+ isInitialized: false,
+ error: null,
+
+ addAddon: (id: string, manifestUrl: string, manifest: Manifest) => {
+ const { addons, configsByProfile, addonOrderByProfile, hasAddon } = get();
+ const activeProfileId = useProfileStore.getState().activeProfileId;
+
+ // Prevent duplicates
+ if (hasAddon(id)) {
+ set({ error: 'Addon already installed' });
+ return;
+ }
- updateAddon: (id: string, manifest: Manifest) => {
- const { addons } = get();
- const existingAddon = addons[id];
+ const newAddon: InstalledAddon = {
+ id,
+ manifestUrl,
+ manifest,
+ installedAt: Date.now(),
+ };
- if (!existingAddon) {
- set({ error: 'Addon not found' });
- return;
- }
+ const newConfigs = { ...configsByProfile };
+ const newOrder = { ...addonOrderByProfile };
+ if (activeProfileId) {
+ if (!newConfigs[activeProfileId]) {
+ newConfigs[activeProfileId] = {};
+ }
+ newConfigs[activeProfileId] = {
+ ...newConfigs[activeProfileId],
+ [id]: { isActive: true, useCatalogsOnHome: true, useCatalogsInSearch: true, useForSubtitles: true },
+ };
+ // Append to end of order for this profile
+ newOrder[activeProfileId] = [...(newOrder[activeProfileId] ?? []), id];
+ }
- set((state) => ({
- addons: {
- ...state.addons,
- [id]: {
- ...existingAddon,
- manifest,
- },
- },
- error: null,
- }));
- },
+ set({
+ addons: { ...addons, [id]: newAddon },
+ configsByProfile: newConfigs,
+ addonOrderByProfile: newOrder,
+ error: null,
+ });
+ },
+
+ removeAddon: (id: string) => {
+ set((state) => {
+ const { [id]: removed, ...rest } = state.addons;
+
+ // Remove config from all profiles
+ const newConfigs = { ...state.configsByProfile };
+ for (const profileId of Object.keys(newConfigs)) {
+ if (newConfigs[profileId] && newConfigs[profileId][id]) {
+ const { [id]: _, ...restConfigs } = newConfigs[profileId];
+ newConfigs[profileId] = restConfigs;
+ }
+ }
- toggleUseCatalogsOnHome: (id: string, profileId?: string) => {
- const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
- if (!targetProfileId) return;
+ // Remove from order in all profiles
+ const newOrder = { ...state.addonOrderByProfile };
+ for (const profileId of Object.keys(newOrder)) {
+ newOrder[profileId] = newOrder[profileId].filter((addonId) => addonId !== id);
+ }
- set((state) => {
- const profileConfigs = state.configsByProfile[targetProfileId];
- if (!profileConfigs || !profileConfigs[id]) return state;
+ return {
+ addons: rest,
+ configsByProfile: newConfigs,
+ addonOrderByProfile: newOrder,
+ error: null,
+ };
+ });
+ },
+
+ activateAddon: (id: string, profileId?: string) => {
+ const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
+ debug('activateAddon', { id, targetProfileId, profileIdArg: profileId });
+ if (!targetProfileId) {
+ debug('activateAddon: no targetProfileId, aborting');
+ return;
+ }
- return {
- configsByProfile: {
- ...state.configsByProfile,
- [targetProfileId]: {
- ...profileConfigs,
- [id]: {
- ...profileConfigs[id],
- useCatalogsOnHome: !profileConfigs[id].useCatalogsOnHome,
+ set((state) => {
+ const profileConfigs = state.configsByProfile[targetProfileId] || {};
+ const existingConfig = profileConfigs[id];
+ debug('activateAddon: existing config', {
+ id, hasExistingConfig: !!existingConfig,
+ previousIsActive: existingConfig?.isActive,
+ });
+
+ // Append to end of order if not already tracked
+ const currentOrder = state.addonOrderByProfile[targetProfileId] ?? [];
+ const newOrder = currentOrder.includes(id) ? currentOrder : [...currentOrder, id];
+
+ return {
+ configsByProfile: {
+ ...state.configsByProfile,
+ [targetProfileId]: {
+ ...profileConfigs,
+ [id]: {
+ ...existingConfig,
+ isActive: true,
+ },
},
},
- },
- };
- });
- },
-
- toggleUseCatalogsInSearch: (id: string, profileId?: string) => {
- const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
- if (!targetProfileId) return;
+ addonOrderByProfile: {
+ ...state.addonOrderByProfile,
+ [targetProfileId]: newOrder,
+ },
+ };
+ });
+ },
+
+ deactivateAddon: (id: string, profileId?: string) => {
+ const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
+ debug('deactivateAddon', { id, targetProfileId, profileIdArg: profileId });
+ if (!targetProfileId) {
+ debug('deactivateAddon: no targetProfileId, aborting');
+ return;
+ }
- set((state) => {
- const profileConfigs = state.configsByProfile[targetProfileId];
- if (!profileConfigs || !profileConfigs[id]) return state;
+ set((state) => {
+ const profileConfigs = state.configsByProfile[targetProfileId];
+ if (!profileConfigs || !profileConfigs[id]) {
+ debug('deactivateAddon: no config entry, silently aborting', {
+ targetProfileId, id, hasProfileConfigs: !!profileConfigs,
+ profileAddonIds: profileConfigs ? Object.keys(profileConfigs) : [],
+ });
+ return state;
+ }
- return {
- configsByProfile: {
- ...state.configsByProfile,
- [targetProfileId]: {
- ...profileConfigs,
- [id]: {
- ...profileConfigs[id],
- useCatalogsInSearch: !profileConfigs[id].useCatalogsInSearch,
+ debug('deactivateAddon: deactivating', {
+ id, previousIsActive: profileConfigs[id].isActive,
+ });
+ return {
+ configsByProfile: {
+ ...state.configsByProfile,
+ [targetProfileId]: {
+ ...profileConfigs,
+ [id]: {
+ ...profileConfigs[id],
+ isActive: false,
+ },
},
},
- },
- };
- });
- },
+ };
+ });
+ },
- toggleUseForSubtitles: (id: string, profileId?: string) => {
- const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
- if (!targetProfileId) return;
+ updateAddon: (id: string, manifest: Manifest) => {
+ const { addons } = get();
+ const existingAddon = addons[id];
- set((state) => {
- const profileConfigs = state.configsByProfile[targetProfileId];
- if (!profileConfigs || !profileConfigs[id]) return state;
+ if (!existingAddon) {
+ set({ error: 'Addon not found' });
+ return;
+ }
- return {
- configsByProfile: {
- ...state.configsByProfile,
- [targetProfileId]: {
- ...profileConfigs,
- [id]: {
- ...profileConfigs[id],
- useForSubtitles: !profileConfigs[id].useForSubtitles,
- },
+ set((state) => ({
+ addons: {
+ ...state.addons,
+ [id]: {
+ ...existingAddon,
+ manifest,
},
},
- };
- });
- },
-
- hasAddons: () => {
- return Object.keys(get().addons).length > 0;
- },
+ error: null,
+ }));
+ },
- hasAddon: (id: string) => {
- return id in get().addons;
- },
+ toggleUseCatalogsOnHome: (id: string, profileId?: string) =>
+ toggleConfigField(id, 'useCatalogsOnHome', profileId),
- getAddonConfig: (id: string, profileId?: string) => {
- const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
- if (!targetProfileId) return undefined;
-
- // Fall back to DEFAULT_ADDON_CONFIG when no per-profile entry exists.
- // This recovers users whose configsByProfile was not populated during the
- // broken v2 migration (async migrate is not supported by Zustand's newImpl).
- const config = get().configsByProfile[targetProfileId]?.[id];
- if (!config) return DEFAULT_ADDON_CONFIG;
- return { ...DEFAULT_ADDON_CONFIG, ...config };
- },
+ toggleUseCatalogsInSearch: (id: string, profileId?: string) =>
+ toggleConfigField(id, 'useCatalogsInSearch', profileId),
- getAddonsList: () => {
- return Object.values(get().addons);
- },
+ toggleUseForSubtitles: (id: string, profileId?: string) =>
+ toggleConfigField(id, 'useForSubtitles', profileId),
- getOrderedAddonsList: (profileId?: string) => {
- const { addons, addonOrderByProfile } = get();
- const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
- const allAddons = Object.values(addons);
- if (!targetProfileId) return allAddons;
+ setAddonConfig: (id: string, config: Partial, profileId?: string) => {
+ const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
+ if (!targetProfileId) {
+ debug('setAddonConfig: no targetProfileId, aborting');
+ return;
+ }
- const order = addonOrderByProfile[targetProfileId];
- if (!order || order.length === 0) return allAddons;
+ set((state) => {
+ const profileConfigs = state.configsByProfile[targetProfileId] ?? {};
+ const existing = profileConfigs[id] ?? { isActive: false, useCatalogsOnHome: true, useCatalogsInSearch: true, useForSubtitles: true };
+ const next: AddonConfig = { ...existing, ...config };
- // Sort by order, addons not in order go to the end
- const orderMap = new Map(order.map((id, index) => [id, index]));
- return [...allAddons].sort((a, b) => {
- const aIndex = orderMap.get(a.id) ?? Infinity;
- const bIndex = orderMap.get(b.id) ?? Infinity;
- return aIndex - bIndex;
- });
- },
+ debug('setAddonConfig', { id, targetProfileId, keys: Object.keys(config) });
- reorderAddon: (fromIndex: number, toIndex: number, profileId?: string) => {
- const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
- if (!targetProfileId) return;
+ // Ensure the addon is in the profile's order list when activating.
+ const currentOrder = state.addonOrderByProfile[targetProfileId] ?? [];
+ const newOrder = next.isActive && !currentOrder.includes(id)
+ ? [...currentOrder, id]
+ : currentOrder;
- set((state) => {
- const { addons, addonOrderByProfile } = state;
- // Build current order, falling back to insertion order for missing entries
- const allIds = Object.keys(addons);
- const currentOrder = addonOrderByProfile[targetProfileId] ?? allIds;
- // Ensure all addon IDs are represented (handles addons added before order tracking)
- const fullOrder = [...currentOrder, ...allIds.filter((id) => !currentOrder.includes(id))];
- const newOrder = moveItem(fullOrder, fromIndex, toIndex);
- return {
- addonOrderByProfile: {
- ...addonOrderByProfile,
- [targetProfileId]: newOrder,
- },
- };
- });
- },
-
- setLoading: (isLoading: boolean) => {
- set({ isLoading });
- },
+ return {
+ configsByProfile: {
+ ...state.configsByProfile,
+ [targetProfileId]: { ...profileConfigs, [id]: next },
+ },
+ ...(newOrder !== currentOrder
+ ? { addonOrderByProfile: { ...state.addonOrderByProfile, [targetProfileId]: newOrder } }
+ : {}),
+ };
+ });
+ },
+
+ hasAddons: () => {
+ return Object.keys(get().addons).length > 0;
+ },
+
+ hasAddon: (id: string) => {
+ return id in get().addons;
+ },
+
+ getAddonConfig: (id: string, profileId?: string) => {
+ const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
+ if (!targetProfileId) {
+ debug('getAddonConfig: no targetProfileId', { id, profileIdArg: profileId });
+ return undefined;
+ }
- setInitialized: (isInitialized: boolean) => {
- set({ isInitialized });
- },
+ const profileConfigs = get().configsByProfile[targetProfileId];
+ const config = profileConfigs?.[id];
+ if (config) {
+ return config;
+ }
- setError: (error: string | null) => {
- set({ error });
- },
- }),
+ debug('getAddonConfig: no config found', { id, targetProfileId });
+ return undefined;
+ },
+
+ getAddonsList: () => {
+ return Object.values(get().addons);
+ },
+
+ getOrderedAddonsList: (profileId?: string) => {
+ const { addons, addonOrderByProfile } = get();
+ const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
+ const allAddons = Object.values(addons);
+
+ if (!targetProfileId) return allAddons;
+
+ const order = addonOrderByProfile[targetProfileId];
+ if (!order || order.length === 0) return allAddons;
+
+ // Sort by order; addons not in order go to the end, with a stable tiebreaker.
+ const orderMap = new Map(order.map((id, index) => [id, index]));
+ return [...allAddons].sort((a, b) => {
+ const aIndex = orderMap.get(a.id) ?? Number.MAX_SAFE_INTEGER;
+ const bIndex = orderMap.get(b.id) ?? Number.MAX_SAFE_INTEGER;
+ return aIndex - bIndex || a.id.localeCompare(b.id);
+ });
+ },
+
+ reorderAddon: (fromIndex: number, toIndex: number, profileId?: string) => {
+ const targetProfileId = profileId || useProfileStore.getState().activeProfileId;
+ if (!targetProfileId) return;
+
+ set((state) => {
+ const { addons, addonOrderByProfile } = state;
+ // Build current order, falling back to insertion order for missing entries
+ const allIds = Object.keys(addons);
+ const currentOrder = addonOrderByProfile[targetProfileId] ?? allIds;
+ // Ensure all addon IDs are represented (handles addons added before order tracking)
+ const fullOrder = [...currentOrder, ...allIds.filter((id) => !currentOrder.includes(id))];
+ const newOrder = moveItem(fullOrder, fromIndex, toIndex);
+ return {
+ addonOrderByProfile: {
+ ...addonOrderByProfile,
+ [targetProfileId]: newOrder,
+ },
+ };
+ });
+ },
+
+ setLoading: (isLoading: boolean) => {
+ set({ isLoading });
+ },
+
+ setInitialized: (isInitialized: boolean) => {
+ set({ isInitialized });
+ },
+
+ setError: (error: string | null) => {
+ set({ error });
+ },
+ };
+ },
{
name: 'addon-storage',
storage: createJSONStorage(() => AsyncStorage),
@@ -364,15 +393,10 @@ export const useAddonStore = create()(
// used in 0.9.0 was silently broken: Zustand's newImpl does not
// await the Promise returned by migrate, so configsByProfile was
// never populated and all addons appeared inactive/invisible.
- // v2 -> v3: No structural change. The missing-config case is now handled at
- // read time: getAddonConfig falls back to DEFAULT_ADDON_CONFIG so
- // addons with no per-profile config entry are treated as active.
- // This recovers users affected by the v2 migration failure without
- // requiring profile data at migration time.
-
+ // v2 -> v3: No structural change. Addons without per-profile config entries
+ // are now treated as inactive (users must re-enable them).
if (version < 2 && state.addons) {
// Strip legacy flat fields off addon objects (useCatalogsOnHome etc.).
- // configsByProfile will be populated on first access via the fallback.
const migratedAddons: Record = {};
for (const [id, addon] of Object.entries(state.addons) as [string, any][]) {
migratedAddons[id] = {
@@ -396,9 +420,10 @@ export const initializeAddons = async () => {
const { addons, updateAddon, setInitialized, setLoading, isInitialized } =
useAddonStore.getState();
- if (isInitialized) {
+ if (isInitialized || initializing) {
return;
}
+ initializing = true;
setLoading(true);
@@ -417,5 +442,6 @@ export const initializeAddons = async () => {
} finally {
setLoading(false);
setInitialized(true);
+ initializing = false;
}
};
diff --git a/src/store/local-server.store.ts b/src/store/local-server.store.ts
new file mode 100644
index 0000000..d88e7a1
--- /dev/null
+++ b/src/store/local-server.store.ts
@@ -0,0 +1,66 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { create } from 'zustand';
+import { createJSONStorage, persist } from 'zustand/middleware';
+
+import { LOCAL_SERVER_PORT, startLocalServer, stopLocalServer } from '@/api/local-server/server';
+import { createDebugLogger } from '@/utils/debug';
+
+const debug = createDebugLogger('LocalServer');
+
+interface LocalServerState {
+ pin: string;
+ isRunning: boolean;
+ port: number;
+ generatePin: () => void;
+ startServer: () => Promise;
+ stopServer: () => Promise;
+}
+
+function generateRandomPin(): string {
+ return Math.floor(100000 + Math.random() * 900000).toString();
+}
+
+export const useLocalServerStore = create()(
+ persist(
+ (set, get) => ({
+ pin: generateRandomPin(),
+ isRunning: false,
+ port: LOCAL_SERVER_PORT,
+ generatePin: () => {
+ const newPin = generateRandomPin();
+ set({ pin: newPin });
+
+ if (get().isRunning) {
+ debug('generatePin: server running, restarting with new pin');
+ stopLocalServer()
+ .then(() => startLocalServer(newPin))
+ .catch(() => {
+ debug('generatePin: restart failed, marking server stopped');
+ set({ isRunning: false });
+ });
+ }
+ },
+ startServer: async () => {
+ const { pin } = get();
+ debug('startServer: starting on port', LOCAL_SERVER_PORT);
+ await startLocalServer(pin);
+ set({ isRunning: true });
+ debug('startServer: started successfully');
+ },
+ stopServer: async () => {
+ try {
+ await stopLocalServer();
+ } catch (err) {
+ debug('stopServer: stopLocalServer failed', err);
+ } finally {
+ set({ isRunning: false });
+ }
+ },
+ }),
+ {
+ name: 'local-server-store',
+ storage: createJSONStorage(() => AsyncStorage),
+ partialize: (state) => ({ pin: state.pin }),
+ }
+ )
+);
diff --git a/src/types/addon-config.ts b/src/types/addon-config.ts
new file mode 100644
index 0000000..2daeacf
--- /dev/null
+++ b/src/types/addon-config.ts
@@ -0,0 +1,10 @@
+/**
+ * Per-profile addon configuration. Shared between the local addon store and
+ * the remote UI so both boundaries stay in sync.
+ */
+export interface AddonConfig {
+ isActive: boolean;
+ useCatalogsOnHome: boolean;
+ useCatalogsInSearch: boolean;
+ useForSubtitles: boolean;
+}
diff --git a/tsconfig.json b/tsconfig.json
index 14c9f25..28be065 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -7,5 +7,6 @@
"@/*": ["src/*"]
}
},
- "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
+ "exclude": ["remote-ui"]
}