sharable eq

This commit is contained in:
Boof2015
2026-07-07 15:52:12 -04:00
parent d05e45e7d3
commit ed6d587f5f
12 changed files with 1323 additions and 31 deletions
+3 -2
View File
@@ -32,7 +32,7 @@
[ [
"expo-camera", "expo-camera",
{ {
"cameraPermission": "Allow Astra to scan desktop pairing QR codes." "cameraPermission": "Allow Astra to scan desktop pairing and EQ preset QR codes."
} }
], ],
[ [
@@ -53,7 +53,8 @@
"usesCleartextTraffic": true "usesCleartextTraffic": true
} }
} }
] ],
"expo-sharing"
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,
+247 -14
View File
@@ -33,6 +33,7 @@
"expo-linking": "~56.0.11", "expo-linking": "~56.0.11",
"expo-router": "~56.2.6", "expo-router": "~56.2.6",
"expo-secure-store": "^56.0.4", "expo-secure-store": "^56.0.4",
"expo-sharing": "~56.0.21",
"expo-splash-screen": "~56.0.10", "expo-splash-screen": "~56.0.10",
"expo-status-bar": "~56.0.4", "expo-status-bar": "~56.0.4",
"expo-symbols": "~56.0.5", "expo-symbols": "~56.0.5",
@@ -44,6 +45,7 @@
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-native": "0.85.3", "react-native": "0.85.3",
"react-native-gesture-handler": "~2.31.1", "react-native-gesture-handler": "~2.31.1",
"react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "4.3.1", "react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0", "react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2", "react-native-screens": "4.25.2",
@@ -1459,15 +1461,15 @@
} }
}, },
"node_modules/@expo/config-plugins": { "node_modules/@expo/config-plugins": {
"version": "56.0.8", "version": "56.0.12",
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.8.tgz", "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.12.tgz",
"integrity": "sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==", "integrity": "sha512-UKjPEOkvxBzTvjghmjUECURDoJLPJIFIevB0JQCe8l9Fg4yfy2fabU5LU3Kfrmmu1/8et/93bucCnABEmoxYEw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/config-types": "^56.0.5", "@expo/config-types": "^56.0.7",
"@expo/json-file": "~10.2.0", "@expo/json-file": "~10.2.0",
"@expo/plist": "^0.7.0", "@expo/plist": "^0.7.0",
"@expo/require-utils": "^56.1.3", "@expo/require-utils": "^56.1.4",
"@expo/sdk-runtime-versions": "^1.0.0", "@expo/sdk-runtime-versions": "^1.0.0",
"chalk": "^4.1.2", "chalk": "^4.1.2",
"debug": "^4.3.5", "debug": "^4.3.5",
@@ -1492,9 +1494,9 @@
} }
}, },
"node_modules/@expo/config-types": { "node_modules/@expo/config-types": {
"version": "56.0.5", "version": "56.0.7",
"resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.5.tgz", "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.7.tgz",
"integrity": "sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==", "integrity": "sha512-V7bxawNsNned/yMppAHdisIOxniZXgPKRWpIUiQOQBs45/A5MBd2gfDM4Ecq5gnbilnQUTaI6Zxn6JcW7L3TAA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@expo/config/node_modules/semver": { "node_modules/@expo/config/node_modules/semver": {
@@ -1856,9 +1858,9 @@
} }
}, },
"node_modules/@expo/require-utils": { "node_modules/@expo/require-utils": {
"version": "56.1.3", "version": "56.1.4",
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.4.tgz",
"integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", "integrity": "sha512-IX7XXg9obnrH3ni0TClBbVKgqk0nT0bjTEPTBtbD+WgIeZ0hpcgwxMJH3j5uBsUqEAq1jqUAkJWrPcg7ZRSV7g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.20.0", "@babel/code-frame": "^7.20.0",
@@ -5117,6 +5119,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-uri-component": { "node_modules/decode-uri-component": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
@@ -5233,6 +5244,12 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dnssd-advertise": { "node_modules/dnssd-advertise": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.4.tgz", "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.4.tgz",
@@ -6533,6 +6550,22 @@
"node": ">=20.16.0" "node": ">=20.16.0"
} }
}, },
"node_modules/expo-sharing": {
"version": "56.0.21",
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.21.tgz",
"integrity": "sha512-X+NmHbjxF0jIfQRm14DlI9aJTwl2U6sVIEmVST4twFLekv3KHDUn2znzLZsEHOI/MskRevszfXP68rj1a8R4Xg==",
"license": "MIT",
"dependencies": {
"@expo/config-plugins": "^56.0.12",
"@expo/config-types": "^56.0.7",
"@expo/plist": "^0.7.0"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-splash-screen": { "node_modules/expo-splash-screen": {
"version": "56.0.10", "version": "56.0.10",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-56.0.10.tgz", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-56.0.10.tgz",
@@ -9919,6 +9952,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -10016,7 +10058,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -10242,7 +10283,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@@ -10254,7 +10294,6 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/punycode": { "node_modules/punycode": {
@@ -10267,6 +10306,159 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/query-string": { "node_modules/query-string": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
@@ -10459,6 +10651,22 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-qrcode-svg": {
"version": "6.3.21",
"resolved": "https://registry.npmjs.org/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.21.tgz",
"integrity": "sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.0",
"qrcode": "^1.5.4",
"text-encoding": "^0.7.0"
},
"peerDependencies": {
"react": "*",
"react-native": ">=0.63.4",
"react-native-svg": ">=14.0.0"
}
},
"node_modules/react-native-reanimated": { "node_modules/react-native-reanimated": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz",
@@ -10886,6 +11094,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -11152,6 +11366,12 @@
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -11761,6 +11981,13 @@
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/text-encoding": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz",
"integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==",
"deprecated": "no longer maintained",
"license": "(Unlicense OR Apache-2.0)"
},
"node_modules/throat": { "node_modules/throat": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz",
@@ -12421,6 +12648,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.22", "version": "1.1.22",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
+3
View File
@@ -27,6 +27,7 @@
"expo-linking": "~56.0.11", "expo-linking": "~56.0.11",
"expo-router": "~56.2.6", "expo-router": "~56.2.6",
"expo-secure-store": "^56.0.4", "expo-secure-store": "^56.0.4",
"expo-sharing": "~56.0.21",
"expo-splash-screen": "~56.0.10", "expo-splash-screen": "~56.0.10",
"expo-status-bar": "~56.0.4", "expo-status-bar": "~56.0.4",
"expo-symbols": "~56.0.5", "expo-symbols": "~56.0.5",
@@ -38,6 +39,7 @@
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-native": "0.85.3", "react-native": "0.85.3",
"react-native-gesture-handler": "~2.31.1", "react-native-gesture-handler": "~2.31.1",
"react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "4.3.1", "react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0", "react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2", "react-native-screens": "4.25.2",
@@ -67,6 +69,7 @@
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts src/db/dynamicPlaylistSql.test.mts", "test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts src/db/dynamicPlaylistSql.test.mts",
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts", "test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
"test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/shared/sync/conflictPreview.test.mts", "test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/shared/sync/conflictPreview.test.mts",
"test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"postinstall": "patch-package" "postinstall": "patch-package"
}, },
+198 -4
View File
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { import {
Alert,
InteractionManager, InteractionManager,
Pressable, Pressable,
StyleSheet, StyleSheet,
@@ -7,10 +8,16 @@ import {
useWindowDimensions useWindowDimensions
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from 'expo-router'; import { useFocusEffect, useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as DocumentPicker from 'expo-document-picker'; import * as DocumentPicker from 'expo-document-picker';
import { readAsStringAsync } from 'expo-file-system/legacy'; import * as Sharing from 'expo-sharing';
import {
StorageAccessFramework,
cacheDirectory,
readAsStringAsync,
writeAsStringAsync,
} from 'expo-file-system/legacy';
import { Screen } from '@/components/Screen'; import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { EQGraph } from '@/components/eq/EQGraph'; import { EQGraph } from '@/components/eq/EQGraph';
@@ -23,6 +30,9 @@ import { EQValueEditSheet } from '@/components/eq/EQValueEditSheet';
import { GraphicEQPanel } from '@/components/eq/GraphicEQPanel'; import { GraphicEQPanel } from '@/components/eq/GraphicEQPanel';
import { PresetSheet } from '@/components/eq/PresetSheet'; import { PresetSheet } from '@/components/eq/PresetSheet';
import { SavePresetSheet } from '@/components/eq/SavePresetSheet'; import { SavePresetSheet } from '@/components/eq/SavePresetSheet';
import { EQPresetNameSheet } from '@/components/eq/EQPresetNameSheet';
import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet';
import { EQPresetQrSheet } from '@/components/eq/EQPresetQrSheet';
import { import {
radius, radius,
spacing, spacing,
@@ -44,21 +54,36 @@ import {
isPassEQBandType isPassEQBandType
} from '@/audio/eq'; } from '@/audio/eq';
import { parseAutoEQ } from '@/audio/autoEQParser'; import { parseAutoEQ } from '@/audio/autoEQParser';
import { buildGraphicBands } from '@/audio/graphicEq';
import { genEqId } from '@/audio/eqPresets';
import {
EQ_PRESET_MIME_TYPE,
buildEQPresetFileName,
encodeEQPresetQr,
parseEQPresetFileContents,
stringifyEQPresetFileContents,
} from '@/audio/eqShare';
import { BAND_TYPE_LABEL, formatGain } from '@/components/eq/format'; import { BAND_TYPE_LABEL, formatGain } from '@/components/eq/format';
import type { EQBand, EQBandType } from '@/types/audio'; import type { EQBand, EQBandType, EQPreset } from '@/types/audio';
type SheetKind = 'none' | 'preset' | 'save' | 'overflow' | 'type'; type SheetKind = 'none' | 'preset' | 'save' | 'overflow' | 'type' | 'shareName' | 'qr' | 'preview';
type CurrentPresetAction = 'export' | 'share' | 'qr';
type EQState = ReturnType<typeof useEQStore.getState>;
const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass']; const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass'];
export default function EQScreen() { export default function EQScreen() {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const router = useRouter();
const eq = useEQStore(); const eq = useEQStore();
const scopeActive = useScopeActive(); const scopeActive = useScopeActive();
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const [sheet, setSheet] = useState<SheetKind>('none'); const [sheet, setSheet] = useState<SheetKind>('none');
const [editingValue, setEditingValue] = useState<EQEditableValue | null>(null); const [editingValue, setEditingValue] = useState<EQEditableValue | null>(null);
const [pendingCurrentAction, setPendingCurrentAction] = useState<CurrentPresetAction | null>(null);
const [pendingImportPreset, setPendingImportPreset] = useState<EQPreset | null>(null);
const [qrPreset, setQrPreset] = useState<{ name: string; value: string } | null>(null);
const closeSheet = useCallback(() => setSheet('none'), []); const closeSheet = useCallback(() => setSheet('none'), []);
const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -103,6 +128,91 @@ export default function EQScreen() {
} }
}; };
const showPresetImportError = useCallback((message = 'That file is not an Astra EQ preset.') => {
Alert.alert('Could not import preset', message);
}, []);
const runCurrentPresetAction = useCallback(async (action: CurrentPresetAction, name: string) => {
const preset = buildCurrentEQPreset(useEQStore.getState(), name);
try {
if (action === 'export') {
const permission = await StorageAccessFramework.requestDirectoryPermissionsAsync();
if (!permission.granted) return;
const fileName = buildEQPresetFileName(preset.name);
const fileUri = await StorageAccessFramework.createFileAsync(
permission.directoryUri,
fileName,
EQ_PRESET_MIME_TYPE
);
await writeAsStringAsync(fileUri, stringifyEQPresetFileContents(preset));
Alert.alert('Preset exported', `Saved ${fileName}.`);
return;
}
if (action === 'share') {
const available = await Sharing.isAvailableAsync();
if (!available) {
Alert.alert('Share unavailable', 'This device cannot open a share sheet right now.');
return;
}
if (!cacheDirectory) {
Alert.alert('Share unavailable', 'Astra could not create a temporary preset file.');
return;
}
const fileUri = `${cacheDirectory}${buildEQPresetFileName(preset.name)}`;
await writeAsStringAsync(fileUri, stringifyEQPresetFileContents(preset));
await Sharing.shareAsync(fileUri, {
mimeType: EQ_PRESET_MIME_TYPE,
dialogTitle: `Share ${preset.name}`,
UTI: 'public.json',
});
return;
}
setQrPreset({ name: preset.name, value: encodeEQPresetQr(preset) });
setSheet('qr');
} catch {
Alert.alert('Preset sharing failed', 'Astra could not finish that preset sharing action.');
}
}, []);
const startCurrentPresetAction = useCallback(
(action: CurrentPresetAction) => {
const activeName = getActivePresetName(useEQStore.getState());
if (activeName) {
closeSheet();
void runCurrentPresetAction(action, activeName);
return;
}
setPendingCurrentAction(action);
setSheet('shareName');
},
[closeSheet, runCurrentPresetAction]
);
const handleNamedCurrentPresetAction = useCallback(
(name: string) => {
const action = pendingCurrentAction;
setPendingCurrentAction(null);
if (!action) return;
void runCurrentPresetAction(action, name);
},
[pendingCurrentAction, runCurrentPresetAction]
);
const handleImportAstraPreset = async () => {
closeSheet();
try {
const res = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
if (res.canceled || !res.assets?.[0]) return;
const content = await readAsStringAsync(res.assets[0].uri);
setPendingImportPreset(parseEQPresetFileContents(content, genEqId));
setSheet('preview');
} catch (error) {
showPresetImportError(error instanceof Error ? error.message : undefined);
}
};
const presetRowEl = ( const presetRowEl = (
<Pressable <Pressable
style={[styles.presetRow, isWide && styles.sideItem]} style={[styles.presetRow, isWide && styles.sideItem]}
@@ -266,6 +376,18 @@ export default function EQScreen() {
{sheet === 'overflow' ? ( {sheet === 'overflow' ? (
<EqSheet onClose={closeSheet}> <EqSheet onClose={closeSheet}>
<EqSheetItem label="Export preset..." icon="folder-outline" onPress={() => startCurrentPresetAction('export')} />
<EqSheetItem label="Share preset..." icon="share-outline" onPress={() => startCurrentPresetAction('share')} />
<EqSheetItem label="Show preset QR..." icon="qr-code-outline" onPress={() => startCurrentPresetAction('qr')} />
<EqSheetItem label="Import Astra preset..." icon="download-outline" onPress={handleImportAstraPreset} />
<EqSheetItem
label="Scan preset QR..."
icon="scan-outline"
onPress={() => {
closeSheet();
router.push('/eq/scan' as never);
}}
/>
<EqSheetItem label="Import AutoEQ…" icon="download-outline" onPress={handleImportAutoEQ} /> <EqSheetItem label="Import AutoEQ…" icon="download-outline" onPress={handleImportAutoEQ} />
{!isGraphic && eq.bands.length > 1 && activeBand ? ( {!isGraphic && eq.bands.length > 1 && activeBand ? (
<EqSheetItem <EqSheetItem
@@ -289,6 +411,40 @@ export default function EQScreen() {
</EqSheet> </EqSheet>
) : null} ) : null}
{sheet === 'shareName' && pendingCurrentAction ? (
<EQPresetNameSheet
defaultName={defaultPresetName}
actionLabel={getCurrentActionLabel(pendingCurrentAction)}
onSubmit={handleNamedCurrentPresetAction}
onClose={closeSheet}
/>
) : null}
{sheet === 'qr' && qrPreset ? (
<EQPresetQrSheet
presetName={qrPreset.name}
value={qrPreset.value}
onClose={() => {
setQrPreset(null);
closeSheet();
}}
/>
) : null}
{sheet === 'preview' && pendingImportPreset ? (
<EQPresetPreviewSheet
preset={pendingImportPreset}
onConfirm={() => {
eq.importPreset(pendingImportPreset);
setPendingImportPreset(null);
}}
onClose={() => {
setPendingImportPreset(null);
closeSheet();
}}
/>
) : null}
{sheet === 'type' && activeBand ? ( {sheet === 'type' && activeBand ? (
<EqSheet onClose={closeSheet}> <EqSheet onClose={closeSheet}>
<Text variant="heading" style={styles.sheetTitle}> <Text variant="heading" style={styles.sheetTitle}>
@@ -396,6 +552,44 @@ function parsePlainNumber(value: string): number | null {
return Number.isFinite(parsed) ? parsed : null; return Number.isFinite(parsed) ? parsed : null;
} }
function getActivePresetName(eq: EQState): string | null {
if (!eq.activePresetId) return null;
const name = eq.presets.find((preset) => preset.id === eq.activePresetId)?.name.trim();
return name && name.length > 0 ? name : null;
}
function buildCurrentEQPreset(eq: EQState, name: string): EQPreset {
if (eq.mode === 'graphic') {
return {
id: 'current-eq',
name,
preamp: eq.preamp,
bands: buildGraphicBands(eq.graphicGains).map((band) => ({ ...band })),
mode: 'graphic',
graphicGains: [...eq.graphicGains],
isCustom: true,
};
}
return {
id: 'current-eq',
name,
preamp: eq.preamp,
bands: eq.bands.map((band) => ({ ...band })),
isCustom: true,
};
}
function getCurrentActionLabel(action: CurrentPresetAction): string {
switch (action) {
case 'export':
return 'Export';
case 'share':
return 'Share';
case 'qr':
return 'Show QR';
}
}
const useStyles = createThemedStyles((colors) => ({ const useStyles = createThemedStyles((colors) => ({
header: { header: {
flexDirection: 'row', flexDirection: 'row',
+200
View File
@@ -0,0 +1,200 @@
import { useState } from 'react';
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import {
CameraView,
useCameraPermissions,
type BarcodeScanningResult
} from 'expo-camera';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet';
import { decodeEQPresetQr } from '@/audio/eqShare';
import { genEqId } from '@/audio/eqPresets';
import { useEQStore } from '@/stores/eqStore';
import {
radius,
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import type { EQPreset } from '@/types/audio';
export default function EQPresetScanScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const importPreset = useEQStore((state) => state.importPreset);
const [permission, requestPermission] = useCameraPermissions();
const [pendingPreset, setPendingPreset] = useState<EQPreset | null>(null);
const [error, setError] = useState<string | null>(null);
const locked = pendingPreset !== null || error !== null;
const onScanned = (result: BarcodeScanningResult) => {
if (locked) return;
const data = result.data?.trim();
if (!data) return;
try {
setPendingPreset(decodeEQPresetQr(data, genEqId));
setError(null);
} catch {
setError('That QR does not contain an Astra EQ preset.');
}
};
return (
<Screen>
<View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Equalizer
</Text>
</Pressable>
</View>
<Text variant="title" style={styles.heading}>
Scan EQ preset QR
</Text>
{!permission ? (
<View style={styles.center} />
) : !permission.granted ? (
<View style={styles.permissionCard}>
<Ionicons name="camera-outline" size={28} color={colors.accent} />
<Text variant="body">Camera access is needed to scan EQ preset QR codes.</Text>
<Pressable style={styles.primaryButton} onPress={() => void requestPermission()}>
<Text variant="body" color={colors.accentTextStrong}>
Allow camera
</Text>
</Pressable>
</View>
) : (
<View style={styles.scannerFrame}>
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={locked ? undefined : onScanned}
/>
<View pointerEvents="none" style={styles.scanBox} />
{error ? (
<View style={styles.errorPanel}>
<View style={styles.errorText}>
<Ionicons name="alert-circle-outline" size={20} color={colors.warning} />
<Text variant="body" color={colors.textPrimary} style={styles.errorCopy}>
{error}
</Text>
</View>
<Pressable style={styles.retryButton} onPress={() => setError(null)}>
<Text variant="label" color={colors.accentTextStrong}>
Scan again
</Text>
</Pressable>
</View>
) : null}
</View>
)}
{pendingPreset ? (
<EQPresetPreviewSheet
preset={pendingPreset}
title="Scanned preset"
onConfirm={() => {
importPreset(pendingPreset);
setPendingPreset(null);
router.replace('/eq' as never);
}}
onClose={() => setPendingPreset(null)}
/>
) : null}
</Screen>
);
}
const useStyles = createThemedStyles((colors) => ({
header: {
marginTop: spacing.md,
marginBottom: spacing.lg,
},
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
heading: {
marginBottom: spacing.lg,
},
center: {
flex: 1,
},
permissionCard: {
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
padding: spacing.lg,
gap: spacing.md,
},
primaryButton: {
minHeight: 44,
borderRadius: radius.sm,
backgroundColor: colors.accent,
paddingHorizontal: spacing.lg,
alignItems: 'center',
justifyContent: 'center',
},
scannerFrame: {
flex: 1,
borderRadius: radius.md,
overflow: 'hidden',
backgroundColor: colors.bgSecondary,
marginBottom: spacing.xl,
},
camera: {
flex: 1,
},
scanBox: {
position: 'absolute',
left: '15%',
right: '15%',
top: '25%',
aspectRatio: 1,
borderRadius: radius.md,
borderWidth: 2,
borderColor: colors.accent,
},
errorPanel: {
position: 'absolute',
left: spacing.lg,
right: spacing.lg,
bottom: spacing.lg,
borderRadius: radius.md,
backgroundColor: colors.bgSecondary,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
padding: spacing.md,
gap: spacing.md,
},
errorText: {
flexDirection: 'row',
gap: spacing.sm,
alignItems: 'center',
},
errorCopy: {
flex: 1,
},
retryButton: {
alignSelf: 'flex-end',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.pill,
backgroundColor: colors.accentGlow,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
}));
+53 -10
View File
@@ -3,7 +3,7 @@
// in the EQ screen. Coefficients themselves are computed natively (Kotlin) at the // in the EQ screen. Coefficients themselves are computed natively (Kotlin) at the
// real stream sample rate — here we only flatten band params for the native bridge. // real stream sample rate — here we only flatten band params for the native bridge.
import type { EQBand, EQBandType, EQPreset } from '@/types/audio'; import type { EQBand, EQBandType, EQMode, EQPreset } from '../types/audio';
export const EQ_MIN_GAIN_DB = -12; export const EQ_MIN_GAIN_DB = -12;
export const EQ_MAX_GAIN_DB = 12; export const EQ_MAX_GAIN_DB = 12;
@@ -16,6 +16,7 @@ export const EQ_MAX_BANDS = 10;
export const EQ_MIN_PREAMP_DB = -12; export const EQ_MIN_PREAMP_DB = -12;
export const EQ_MAX_PREAMP_DB = 12; export const EQ_MAX_PREAMP_DB = 12;
export const EQ_PRESET_VERSION = 1; export const EQ_PRESET_VERSION = 1;
export const EQ_GRAPHIC_BAND_COUNT = 5;
// Ordinals MUST match the Kotlin `EqBandType` enum order in EqBridge.kt. // Ordinals MUST match the Kotlin `EqBandType` enum order in EqBridge.kt.
export const EQ_BAND_TYPE_ORDINAL: Record<EQBandType, number> = { export const EQ_BAND_TYPE_ORDINAL: Record<EQBandType, number> = {
@@ -34,6 +35,17 @@ interface RawEQBand {
enabled?: unknown; enabled?: unknown;
} }
type SerializedEQBand = Pick<EQBand, 'type' | 'frequency' | 'gain' | 'Q' | 'enabled'>;
export interface SerializedEQPresetData {
version: number;
name: string;
preamp: number;
bands: SerializedEQBand[];
mode?: EQMode;
graphicGains?: number[];
}
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
} }
@@ -96,15 +108,36 @@ export function createNormalizedEQBand(rawBand: RawEQBand, id: string): EQBand {
return normalizeEQBand(band); return normalizeEQBand(band);
} }
function parseEQGraphicGains(value: unknown): number[] | null {
if (!Array.isArray(value) || value.length !== EQ_GRAPHIC_BAND_COUNT) return null;
const gains: number[] = [];
for (const raw of value) {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return null;
gains.push(clampEQGain(parsed));
}
return gains;
}
export function parseEQPresetData(value: unknown, createId: () => string): EQPreset { export function parseEQPresetData(value: unknown, createId: () => string): EQPreset {
if (!value || typeof value !== 'object' || Array.isArray(value)) { if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Invalid preset file'); throw new Error('Invalid preset file');
} }
const raw = value as { name?: unknown; preamp?: unknown; bands?: unknown }; const raw = value as {
version?: unknown;
name?: unknown;
preamp?: unknown;
bands?: unknown;
mode?: unknown;
graphicGains?: unknown;
};
if (raw.version !== undefined && raw.version !== EQ_PRESET_VERSION) {
throw new Error('Unsupported preset version');
}
if (typeof raw.name !== 'string' || raw.name.trim().length === 0 || !Array.isArray(raw.bands)) { if (typeof raw.name !== 'string' || raw.name.trim().length === 0 || !Array.isArray(raw.bands)) {
throw new Error('Invalid preset file'); throw new Error('Invalid preset file');
} }
return { const preset: EQPreset = {
id: createId(), id: createId(),
name: raw.name.trim(), name: raw.name.trim(),
preamp: clampPreamp(coerceFiniteNumber(raw.preamp, 0)), preamp: clampPreamp(coerceFiniteNumber(raw.preamp, 0)),
@@ -118,15 +151,19 @@ export function parseEQPresetData(value: unknown, createId: () => string): EQPre
), ),
isCustom: true, isCustom: true,
}; };
const graphicGains = raw.mode === 'graphic' ? parseEQGraphicGains(raw.graphicGains) : null;
if (!graphicGains) return preset;
return {
...preset,
mode: 'graphic',
graphicGains,
};
} }
export function serializeEQPresetData(preset: Pick<EQPreset, 'name' | 'preamp' | 'bands'>): { export function serializeEQPresetData(
version: number; preset: Pick<EQPreset, 'name' | 'preamp' | 'bands' | 'mode' | 'graphicGains'>
name: string; ): SerializedEQPresetData {
preamp: number; const data: SerializedEQPresetData = {
bands: Pick<EQBand, 'type' | 'frequency' | 'gain' | 'Q' | 'enabled'>[];
} {
return {
version: EQ_PRESET_VERSION, version: EQ_PRESET_VERSION,
name: preset.name, name: preset.name,
preamp: clampPreamp(coerceFiniteNumber(preset.preamp, 0)), preamp: clampPreamp(coerceFiniteNumber(preset.preamp, 0)),
@@ -135,6 +172,12 @@ export function serializeEQPresetData(preset: Pick<EQPreset, 'name' | 'preamp' |
return { type: n.type, frequency: n.frequency, gain: n.gain, Q: n.Q, enabled: n.enabled }; return { type: n.type, frequency: n.frequency, gain: n.gain, Q: n.Q, enabled: n.enabled };
}), }),
}; };
const graphicGains = preset.mode === 'graphic' ? parseEQGraphicGains(preset.graphicGains) : null;
if (graphicGains) {
data.mode = 'graphic';
data.graphicGains = graphicGains;
}
return data;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { EQBand, EQPreset } from '../types/audio.ts';
import { EQ_MAX_BANDS } from './eq.ts';
import {
EQ_PRESET_QR_PREFIX,
decodeEQPresetQr,
encodeEQPresetQr,
parseEQPresetFileContents,
stringifyEQPresetFileContents,
} from './eqShare.ts';
let idCounter = 0;
function nextId(): string {
idCounter += 1;
return `test-eq-${idCounter}`;
}
function band(overrides: Partial<EQBand> = {}): EQBand {
return {
id: overrides.id ?? nextId(),
type: overrides.type ?? 'peaking',
frequency: overrides.frequency ?? 1000,
gain: overrides.gain ?? 0,
Q: overrides.Q ?? 1,
enabled: overrides.enabled ?? true,
};
}
test('round-trips a parametric Astra EQ preset file', () => {
const source: EQPreset = {
id: 'preset-parametric',
name: 'Desk Parametric',
preamp: -2.5,
bands: [
band({ type: 'lowshelf', frequency: 80, gain: 3.5, Q: 0.707 }),
band({ type: 'peaking', frequency: 1200, gain: -2, Q: 1.4, enabled: false }),
],
};
const parsed = parseEQPresetFileContents(stringifyEQPresetFileContents(source), nextId);
assert.equal(parsed.name, 'Desk Parametric');
assert.equal(parsed.preamp, -2.5);
assert.equal(parsed.mode, undefined);
assert.deepEqual(
parsed.bands.map((b) => ({ type: b.type, frequency: b.frequency, gain: b.gain, Q: b.Q, enabled: b.enabled })),
[
{ type: 'lowshelf', frequency: 80, gain: 3.5, Q: 0.707, enabled: true },
{ type: 'peaking', frequency: 1200, gain: -2, Q: 1.4, enabled: false },
]
);
});
test('round-trips a graphic Astra EQ preset file with editable gains', () => {
const graphicGains = [-2, 0, 3, 2.5, -1];
const source: EQPreset = {
id: 'preset-graphic',
name: 'Graphic Smile',
preamp: -3,
mode: 'graphic',
graphicGains,
bands: graphicGains.map((gain, index) =>
band({ id: `graphic-${index}`, frequency: [60, 250, 1000, 4000, 12000][index], gain })
),
};
const parsed = parseEQPresetFileContents(stringifyEQPresetFileContents(source), nextId);
assert.equal(parsed.mode, 'graphic');
assert.deepEqual(parsed.graphicGains, graphicGains);
assert.equal(parsed.bands.length, 5);
});
test('encodes and decodes a QR payload', () => {
const source: EQPreset = {
id: 'preset-qr',
name: 'QR Preset ✓',
preamp: 1,
bands: [band({ frequency: 777, gain: 4, Q: 1.25 })],
};
const qr = encodeEQPresetQr(source);
assert.ok(qr.startsWith(EQ_PRESET_QR_PREFIX));
const parsed = decodeEQPresetQr(qr, nextId);
assert.equal(parsed.name, 'QR Preset ✓');
assert.equal(parsed.bands[0]?.frequency, 777);
});
test('rejects non-Astra and malformed QR payloads', () => {
assert.throws(() => decodeEQPresetQr('https://example.com', nextId), /Not an Astra EQ preset QR/);
assert.throws(() => decodeEQPresetQr(`${EQ_PRESET_QR_PREFIX}%`, nextId), /Invalid Astra EQ preset QR/);
});
test('rejects invalid preset JSON', () => {
assert.throws(() => parseEQPresetFileContents('{not-json', nextId), /Invalid Astra EQ preset file/);
assert.throws(
() => parseEQPresetFileContents(JSON.stringify({ version: 2, name: 'Future', bands: [] }), nextId),
/Unsupported preset version/
);
});
test('clamps values and truncates imported bands', () => {
const rawBands = Array.from({ length: EQ_MAX_BANDS + 3 }, (_, index) => ({
type: index === 0 ? 'highpass' : 'peaking',
frequency: index === 0 ? 5 : 50000,
gain: 999,
Q: 99,
enabled: true,
}));
const parsed = parseEQPresetFileContents(JSON.stringify({ version: 1, name: 'Wild', preamp: -99, bands: rawBands }), nextId);
assert.equal(parsed.preamp, -12);
assert.equal(parsed.bands.length, EQ_MAX_BANDS);
assert.equal(parsed.bands[0]?.frequency, 20);
assert.equal(parsed.bands[0]?.gain, 0);
assert.equal(parsed.bands[1]?.frequency, 20000);
assert.equal(parsed.bands[1]?.gain, 12);
assert.equal(parsed.bands[1]?.Q, 18);
});
test('does not serialize the master EQ enabled state', () => {
const source = {
id: 'preset-enabled',
name: 'No Master State',
preamp: 0,
enabled: true,
bands: [band()],
} as EQPreset & { enabled: boolean };
const raw = JSON.parse(stringifyEQPresetFileContents(source));
assert.equal('enabled' in raw, false);
});
+152
View File
@@ -0,0 +1,152 @@
import type { EQPreset } from '../types/audio';
import {
parseEQPresetData,
serializeEQPresetData,
} from './eq.ts';
export const EQ_PRESET_FILE_EXTENSION = 'astraeq';
export const EQ_PRESET_MIME_TYPE = 'application/vnd.astra.eq-preset+json';
export const EQ_PRESET_QR_PREFIX = 'astra:eq-preset:v1:';
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function stringToUtf8Bytes(value: string): number[] {
const bytes: number[] = [];
for (const char of value) {
const codePoint = char.codePointAt(0) ?? 0;
if (codePoint <= 0x7f) {
bytes.push(codePoint);
} else if (codePoint <= 0x7ff) {
bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f));
} else if (codePoint <= 0xffff) {
bytes.push(0xe0 | (codePoint >> 12), 0x80 | ((codePoint >> 6) & 0x3f), 0x80 | (codePoint & 0x3f));
} else {
bytes.push(
0xf0 | (codePoint >> 18),
0x80 | ((codePoint >> 12) & 0x3f),
0x80 | ((codePoint >> 6) & 0x3f),
0x80 | (codePoint & 0x3f)
);
}
}
return bytes;
}
function utf8BytesToString(bytes: readonly number[]): string {
let result = '';
for (let i = 0; i < bytes.length; i += 1) {
const first = bytes[i];
if (first <= 0x7f) {
result += String.fromCodePoint(first);
continue;
}
if ((first & 0xe0) === 0xc0) {
const second = bytes[++i];
if (second === undefined) throw new Error('Invalid preset QR');
result += String.fromCodePoint(((first & 0x1f) << 6) | (second & 0x3f));
continue;
}
if ((first & 0xf0) === 0xe0) {
const second = bytes[++i];
const third = bytes[++i];
if (second === undefined || third === undefined) throw new Error('Invalid preset QR');
result += String.fromCodePoint(((first & 0x0f) << 12) | ((second & 0x3f) << 6) | (third & 0x3f));
continue;
}
if ((first & 0xf8) === 0xf0) {
const second = bytes[++i];
const third = bytes[++i];
const fourth = bytes[++i];
if (second === undefined || third === undefined || fourth === undefined) {
throw new Error('Invalid preset QR');
}
result += String.fromCodePoint(
((first & 0x07) << 18) | ((second & 0x3f) << 12) | ((third & 0x3f) << 6) | (fourth & 0x3f)
);
continue;
}
throw new Error('Invalid preset QR');
}
return result;
}
function base64UrlEncode(value: string): string {
const bytes = stringToUtf8Bytes(value);
let result = '';
for (let i = 0; i < bytes.length; i += 3) {
const first = bytes[i];
const second = bytes[i + 1];
const third = bytes[i + 2];
const triple = (first << 16) | ((second ?? 0) << 8) | (third ?? 0);
result += BASE64_ALPHABET[(triple >> 18) & 0x3f];
result += BASE64_ALPHABET[(triple >> 12) & 0x3f];
result += second === undefined ? '=' : BASE64_ALPHABET[(triple >> 6) & 0x3f];
result += third === undefined ? '=' : BASE64_ALPHABET[triple & 0x3f];
}
return result.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlDecode(value: string): string {
if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
throw new Error('Invalid preset QR');
}
const padded = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=');
const bytes: number[] = [];
for (let i = 0; i < padded.length; i += 4) {
const a = BASE64_ALPHABET.indexOf(padded[i]);
const b = BASE64_ALPHABET.indexOf(padded[i + 1]);
const c = padded[i + 2] === '=' ? -1 : BASE64_ALPHABET.indexOf(padded[i + 2]);
const d = padded[i + 3] === '=' ? -1 : BASE64_ALPHABET.indexOf(padded[i + 3]);
if (a < 0 || b < 0 || (c < 0 && padded[i + 2] !== '=') || (d < 0 && padded[i + 3] !== '=')) {
throw new Error('Invalid preset QR');
}
const triple = (a << 18) | (b << 12) | ((c < 0 ? 0 : c) << 6) | (d < 0 ? 0 : d);
bytes.push((triple >> 16) & 0xff);
if (c >= 0) bytes.push((triple >> 8) & 0xff);
if (d >= 0) bytes.push(triple & 0xff);
}
return utf8BytesToString(bytes);
}
export function sanitizeEQPresetFileName(name: string): string {
const cleaned = name.replace(/[\\/:*?"<>|]/g, '_').trim();
return cleaned || 'Astra EQ Preset';
}
export function buildEQPresetFileName(name: string): string {
return `${sanitizeEQPresetFileName(name)}.${EQ_PRESET_FILE_EXTENSION}`;
}
export function stringifyEQPresetFileContents(preset: EQPreset): string {
return `${JSON.stringify(serializeEQPresetData(preset), null, 2)}\n`;
}
export function parseEQPresetFileContents(contents: string, createId: () => string): EQPreset {
try {
return parseEQPresetData(JSON.parse(contents), createId);
} catch (error) {
if (error instanceof Error && error.message === 'Unsupported preset version') {
throw error;
}
throw new Error('Invalid Astra EQ preset file');
}
}
export function encodeEQPresetQr(preset: EQPreset): string {
return `${EQ_PRESET_QR_PREFIX}${base64UrlEncode(JSON.stringify(serializeEQPresetData(preset)))}`;
}
export function decodeEQPresetQr(value: string, createId: () => string): EQPreset {
const trimmed = value.trim();
if (!trimmed.startsWith(EQ_PRESET_QR_PREFIX)) {
throw new Error('Not an Astra EQ preset QR');
}
try {
return parseEQPresetData(JSON.parse(base64UrlDecode(trimmed.slice(EQ_PRESET_QR_PREFIX.length))), createId);
} catch (error) {
if (error instanceof Error && error.message === 'Not an Astra EQ preset QR') {
throw error;
}
throw new Error('Invalid Astra EQ preset QR');
}
}
+115
View File
@@ -0,0 +1,115 @@
import { useState } from 'react';
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import {
fonts,
radius,
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { EqSheet } from './EqSheet';
interface EQPresetNameSheetProps {
defaultName: string;
actionLabel: string;
onSubmit: (name: string) => void;
onClose: () => void;
}
export function EQPresetNameSheet({
defaultName,
actionLabel,
onSubmit,
onClose,
}: EQPresetNameSheetProps) {
const styles = useStyles();
const colors = useColors();
const [name, setName] = useState(defaultName);
const trimmed = name.trim();
const submit = () => {
if (!trimmed) return;
onClose();
onSubmit(trimmed);
};
return (
<EqSheet onClose={onClose}>
<Text variant="heading" style={styles.title}>
Name preset
</Text>
<BottomSheetTextInput
value={name}
onChangeText={setName}
placeholder="Preset name"
placeholderTextColor={colors.textTertiary}
style={styles.input}
autoFocus
selectTextOnFocus
maxLength={40}
returnKeyType="done"
onSubmitEditing={submit}
/>
<View style={styles.actions}>
<Pressable style={[styles.btn, styles.cancel]} onPress={onClose}>
<Text variant="label" color={colors.textSecondary}>
Cancel
</Text>
</Pressable>
<Pressable style={[styles.btn, styles.primary, !trimmed && styles.disabled]} disabled={!trimmed} onPress={submit}>
<Text variant="label" color={colors.accentTextStrong}>
{actionLabel}
</Text>
</Pressable>
</View>
</EqSheet>
);
}
const useStyles = createThemedStyles((colors) => ({
title: {
marginTop: spacing.xs,
marginBottom: spacing.md,
},
input: {
color: colors.textPrimary,
fontFamily: fonts.sans.regular,
fontSize: 16,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg,
},
btn: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.pill,
},
cancel: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
primary: {
backgroundColor: colors.accentGlow,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
disabled: {
opacity: 0.4,
},
}));
export default EQPresetNameSheet;
+133
View File
@@ -0,0 +1,133 @@
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import { Text } from '@/components/Text';
import {
radius,
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import type { EQPreset } from '@/types/audio';
import { EqSheet } from './EqSheet';
import { formatGain } from './format';
interface EQPresetPreviewSheetProps {
preset: EQPreset;
title?: string;
confirmLabel?: string;
onConfirm: () => void;
onClose: () => void;
}
export function EQPresetPreviewSheet({
preset,
title = 'Import preset',
confirmLabel = 'Import and Apply',
onConfirm,
onClose,
}: EQPresetPreviewSheetProps) {
const styles = useStyles();
const colors = useColors();
const enabledBands = preset.bands.filter((band) => band.enabled).length;
const modeLabel = preset.mode === 'graphic' ? 'Graphic' : 'Parametric';
return (
<EqSheet onClose={onClose}>
<Text variant="heading" style={styles.title}>
{title}
</Text>
<View style={styles.preview}>
<Text variant="body" numberOfLines={1} color={colors.textPrimary}>
{preset.name}
</Text>
<View style={styles.metaRow}>
<Text variant="caption" color={colors.textTertiary}>
Mode
</Text>
<Text variant="label" color={colors.textSecondary}>
{modeLabel}
</Text>
</View>
<View style={styles.metaRow}>
<Text variant="caption" color={colors.textTertiary}>
Preamp
</Text>
<Text variant="label" color={colors.textSecondary}>
{formatGain(preset.preamp)} dB
</Text>
</View>
<View style={styles.metaRow}>
<Text variant="caption" color={colors.textTertiary}>
Bands
</Text>
<Text variant="label" color={colors.textSecondary}>
{enabledBands}/{preset.bands.length}
</Text>
</View>
</View>
<View style={styles.actions}>
<Pressable style={[styles.btn, styles.cancel]} onPress={onClose}>
<Text variant="label" color={colors.textSecondary}>
Cancel
</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.primary]}
onPress={() => {
onConfirm();
onClose();
}}
>
<Text variant="label" color={colors.accentTextStrong}>
{confirmLabel}
</Text>
</Pressable>
</View>
</EqSheet>
);
}
const useStyles = createThemedStyles((colors) => ({
title: {
marginTop: spacing.xs,
marginBottom: spacing.md,
},
preview: {
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
padding: spacing.md,
gap: spacing.sm,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg,
},
btn: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderRadius: radius.pill,
},
cancel: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
primary: {
backgroundColor: colors.accentGlow,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
}));
export default EQPresetPreviewSheet;
+75
View File
@@ -0,0 +1,75 @@
import {
Pressable,
StyleSheet,
View
} from 'react-native';
import QRCode from 'react-native-qrcode-svg';
import { Text } from '@/components/Text';
import {
radius,
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { EqSheet } from './EqSheet';
interface EQPresetQrSheetProps {
presetName: string;
value: string;
onClose: () => void;
}
export function EQPresetQrSheet({ presetName, value, onClose }: EQPresetQrSheetProps) {
const styles = useStyles();
const colors = useColors();
return (
<EqSheet onClose={onClose}>
<Text variant="heading" style={styles.title}>
Preset QR
</Text>
<View style={styles.qrWrap}>
<QRCode value={value} size={220} color="#000000" backgroundColor="#ffffff" quietZone={12} ecl="M" />
</View>
<Text variant="label" numberOfLines={1} color={colors.textSecondary} style={styles.name}>
{presetName}
</Text>
<Pressable style={styles.done} onPress={onClose}>
<Text variant="label" color={colors.accentTextStrong}>
Done
</Text>
</Pressable>
</EqSheet>
);
}
const useStyles = createThemedStyles((colors) => ({
title: {
marginTop: spacing.xs,
marginBottom: spacing.md,
},
qrWrap: {
alignSelf: 'center',
padding: spacing.md,
borderRadius: radius.md,
backgroundColor: '#ffffff',
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
name: {
alignSelf: 'center',
maxWidth: 260,
marginTop: spacing.md,
},
done: {
alignSelf: 'flex-end',
marginTop: spacing.lg,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.pill,
backgroundColor: colors.accentGlow,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
}));
export default EQPresetQrSheet;
+9 -1
View File
@@ -379,7 +379,15 @@ export const useEQStore = create<EQStore>((set, get) => {
}, },
importPreset: (preset) => { importPreset: (preset) => {
const stored: EQPreset = { ...preset, id: genEqId(), isCustom: true }; const graphicGains = preset.mode === 'graphic' ? parseGraphicGains(preset.graphicGains) : null;
const stored: EQPreset = {
id: genEqId(),
name: preset.name,
preamp: clampPreamp(preset.preamp),
bands: preset.bands.slice(0, EQ_MAX_BANDS).map((band) => createNormalizedEQBand(band, genEqId())),
isCustom: true,
...(graphicGains ? { mode: 'graphic' as const, graphicGains } : {}),
};
set({ presets: [...get().presets, stored] }); set({ presets: [...get().presets, stored] });
get().applyPreset(stored.id); get().applyPreset(stored.id);
}, },