mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 20:20:18 +02:00
sharable eq
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
Generated
+247
-14
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user