From ed6d587f5f2bce65f985ec8573141003ba305f7e Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:52:12 -0400 Subject: [PATCH] sharable eq --- app.json | 5 +- package-lock.json | 261 +++++++++++++++++++-- package.json | 3 + src/app/(tabs)/eq.tsx | 202 +++++++++++++++- src/app/eq/scan.tsx | 200 ++++++++++++++++ src/audio/eq.ts | 63 ++++- src/audio/eqShare.test.mts | 135 +++++++++++ src/audio/eqShare.ts | 152 ++++++++++++ src/components/eq/EQPresetNameSheet.tsx | 115 +++++++++ src/components/eq/EQPresetPreviewSheet.tsx | 133 +++++++++++ src/components/eq/EQPresetQrSheet.tsx | 75 ++++++ src/stores/eqStore.ts | 10 +- 12 files changed, 1323 insertions(+), 31 deletions(-) create mode 100644 src/app/eq/scan.tsx create mode 100644 src/audio/eqShare.test.mts create mode 100644 src/audio/eqShare.ts create mode 100644 src/components/eq/EQPresetNameSheet.tsx create mode 100644 src/components/eq/EQPresetPreviewSheet.tsx create mode 100644 src/components/eq/EQPresetQrSheet.tsx diff --git a/app.json b/app.json index 64fd209..411a30a 100644 --- a/app.json +++ b/app.json @@ -32,7 +32,7 @@ [ "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 } } - ] + ], + "expo-sharing" ], "experiments": { "typedRoutes": true, diff --git a/package-lock.json b/package-lock.json index 3f15d1d..9efbab8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "expo-linking": "~56.0.11", "expo-router": "~56.2.6", "expo-secure-store": "^56.0.4", + "expo-sharing": "~56.0.21", "expo-splash-screen": "~56.0.10", "expo-status-bar": "~56.0.4", "expo-symbols": "~56.0.5", @@ -44,6 +45,7 @@ "react-dom": "19.2.3", "react-native": "0.85.3", "react-native-gesture-handler": "~2.31.1", + "react-native-qrcode-svg": "^6.3.21", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", @@ -1459,15 +1461,15 @@ } }, "node_modules/@expo/config-plugins": { - "version": "56.0.8", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.8.tgz", - "integrity": "sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==", + "version": "56.0.12", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.12.tgz", + "integrity": "sha512-UKjPEOkvxBzTvjghmjUECURDoJLPJIFIevB0JQCe8l9Fg4yfy2fabU5LU3Kfrmmu1/8et/93bucCnABEmoxYEw==", "license": "MIT", "dependencies": { - "@expo/config-types": "^56.0.5", + "@expo/config-types": "^56.0.7", "@expo/json-file": "~10.2.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", "chalk": "^4.1.2", "debug": "^4.3.5", @@ -1492,9 +1494,9 @@ } }, "node_modules/@expo/config-types": { - "version": "56.0.5", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.5.tgz", - "integrity": "sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==", + "version": "56.0.7", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.7.tgz", + "integrity": "sha512-V7bxawNsNned/yMppAHdisIOxniZXgPKRWpIUiQOQBs45/A5MBd2gfDM4Ecq5gnbilnQUTaI6Zxn6JcW7L3TAA==", "license": "MIT" }, "node_modules/@expo/config/node_modules/semver": { @@ -1856,9 +1858,9 @@ } }, "node_modules/@expo/require-utils": { - "version": "56.1.3", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", - "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "version": "56.1.4", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.4.tgz", + "integrity": "sha512-IX7XXg9obnrH3ni0TClBbVKgqk0nT0bjTEPTBtbD+WgIeZ0hpcgwxMJH3j5uBsUqEAq1jqUAkJWrPcg7ZRSV7g==", "license": "MIT", "dependencies": { "@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": { "version": "0.2.2", "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==", "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": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.4.tgz", @@ -6533,6 +6550,22 @@ "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": { "version": "56.0.10", "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" } }, + "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -10016,7 +10058,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10242,7 +10283,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -10254,7 +10294,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/punycode": { @@ -10267,6 +10306,159 @@ "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": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", @@ -10459,6 +10651,22 @@ "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": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz", @@ -10886,6 +11094,12 @@ "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": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -11152,6 +11366,12 @@ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "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": { "version": "1.2.2", "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==", "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", @@ -12421,6 +12648,12 @@ "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": { "version": "1.1.22", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", diff --git a/package.json b/package.json index 2573d03..1fd8010 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "expo-linking": "~56.0.11", "expo-router": "~56.2.6", "expo-secure-store": "^56.0.4", + "expo-sharing": "~56.0.21", "expo-splash-screen": "~56.0.10", "expo-status-bar": "~56.0.4", "expo-symbols": "~56.0.5", @@ -38,6 +39,7 @@ "react-dom": "19.2.3", "react-native": "0.85.3", "react-native-gesture-handler": "~2.31.1", + "react-native-qrcode-svg": "^6.3.21", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", "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: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:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts", "typecheck": "tsc --noEmit", "postinstall": "patch-package" }, diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx index 8230823..af24d46 100644 --- a/src/app/(tabs)/eq.tsx +++ b/src/app/(tabs)/eq.tsx @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; import { + Alert, InteractionManager, Pressable, StyleSheet, @@ -7,10 +8,16 @@ import { useWindowDimensions } from 'react-native'; 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 * 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 { Text } from '@/components/Text'; import { EQGraph } from '@/components/eq/EQGraph'; @@ -23,6 +30,9 @@ import { EQValueEditSheet } from '@/components/eq/EQValueEditSheet'; import { GraphicEQPanel } from '@/components/eq/GraphicEQPanel'; import { PresetSheet } from '@/components/eq/PresetSheet'; 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 { radius, spacing, @@ -44,21 +54,36 @@ import { isPassEQBandType } from '@/audio/eq'; 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 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; const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass']; export default function EQScreen() { const styles = useStyles(); const colors = useColors(); + const router = useRouter(); const eq = useEQStore(); const scopeActive = useScopeActive(); const [focused, setFocused] = useState(false); const [sheet, setSheet] = useState('none'); const [editingValue, setEditingValue] = useState(null); + const [pendingCurrentAction, setPendingCurrentAction] = useState(null); + const [pendingImportPreset, setPendingImportPreset] = useState(null); + const [qrPreset, setQrPreset] = useState<{ name: string; value: string } | null>(null); const closeSheet = useCallback(() => setSheet('none'), []); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); 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 = ( + startCurrentPresetAction('export')} /> + startCurrentPresetAction('share')} /> + startCurrentPresetAction('qr')} /> + + { + closeSheet(); + router.push('/eq/scan' as never); + }} + /> {!isGraphic && eq.bands.length > 1 && activeBand ? ( ) : null} + {sheet === 'shareName' && pendingCurrentAction ? ( + + ) : null} + + {sheet === 'qr' && qrPreset ? ( + { + setQrPreset(null); + closeSheet(); + }} + /> + ) : null} + + {sheet === 'preview' && pendingImportPreset ? ( + { + eq.importPreset(pendingImportPreset); + setPendingImportPreset(null); + }} + onClose={() => { + setPendingImportPreset(null); + closeSheet(); + }} + /> + ) : null} + {sheet === 'type' && activeBand ? ( @@ -396,6 +552,44 @@ function parsePlainNumber(value: string): number | 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) => ({ header: { flexDirection: 'row', diff --git a/src/app/eq/scan.tsx b/src/app/eq/scan.tsx new file mode 100644 index 0000000..88bdd2c --- /dev/null +++ b/src/app/eq/scan.tsx @@ -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(null); + const [error, setError] = useState(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 ( + + + router.back()} hitSlop={8}> + + + Equalizer + + + + + + Scan EQ preset QR + + + {!permission ? ( + + ) : !permission.granted ? ( + + + Camera access is needed to scan EQ preset QR codes. + void requestPermission()}> + + Allow camera + + + + ) : ( + + + + {error ? ( + + + + + {error} + + + setError(null)}> + + Scan again + + + + ) : null} + + )} + + {pendingPreset ? ( + { + importPreset(pendingPreset); + setPendingPreset(null); + router.replace('/eq' as never); + }} + onClose={() => setPendingPreset(null)} + /> + ) : null} + + ); +} + +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, + }, +})); diff --git a/src/audio/eq.ts b/src/audio/eq.ts index e74c553..379a816 100644 --- a/src/audio/eq.ts +++ b/src/audio/eq.ts @@ -3,7 +3,7 @@ // 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. -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_MAX_GAIN_DB = 12; @@ -16,6 +16,7 @@ export const EQ_MAX_BANDS = 10; export const EQ_MIN_PREAMP_DB = -12; export const EQ_MAX_PREAMP_DB = 12; export const EQ_PRESET_VERSION = 1; +export const EQ_GRAPHIC_BAND_COUNT = 5; // Ordinals MUST match the Kotlin `EqBandType` enum order in EqBridge.kt. export const EQ_BAND_TYPE_ORDINAL: Record = { @@ -34,6 +35,17 @@ interface RawEQBand { enabled?: unknown; } +type SerializedEQBand = Pick; + +export interface SerializedEQPresetData { + version: number; + name: string; + preamp: number; + bands: SerializedEQBand[]; + mode?: EQMode; + graphicGains?: number[]; +} + function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } @@ -96,15 +108,36 @@ export function createNormalizedEQBand(rawBand: RawEQBand, id: string): EQBand { 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 { if (!value || typeof value !== 'object' || Array.isArray(value)) { 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)) { throw new Error('Invalid preset file'); } - return { + const preset: EQPreset = { id: createId(), name: raw.name.trim(), preamp: clampPreamp(coerceFiniteNumber(raw.preamp, 0)), @@ -118,15 +151,19 @@ export function parseEQPresetData(value: unknown, createId: () => string): EQPre ), 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): { - version: number; - name: string; - preamp: number; - bands: Pick[]; -} { - return { +export function serializeEQPresetData( + preset: Pick +): SerializedEQPresetData { + const data: SerializedEQPresetData = { version: EQ_PRESET_VERSION, name: preset.name, preamp: clampPreamp(coerceFiniteNumber(preset.preamp, 0)), @@ -135,6 +172,12 @@ export function serializeEQPresetData(preset: Pick = {}): 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); +}); diff --git a/src/audio/eqShare.ts b/src/audio/eqShare.ts new file mode 100644 index 0000000..0e713de --- /dev/null +++ b/src/audio/eqShare.ts @@ -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'); + } +} diff --git a/src/components/eq/EQPresetNameSheet.tsx b/src/components/eq/EQPresetNameSheet.tsx new file mode 100644 index 0000000..27c6ea3 --- /dev/null +++ b/src/components/eq/EQPresetNameSheet.tsx @@ -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 ( + + + Name preset + + + + + + Cancel + + + + + {actionLabel} + + + + + ); +} + +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; diff --git a/src/components/eq/EQPresetPreviewSheet.tsx b/src/components/eq/EQPresetPreviewSheet.tsx new file mode 100644 index 0000000..dfe4f30 --- /dev/null +++ b/src/components/eq/EQPresetPreviewSheet.tsx @@ -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 ( + + + {title} + + + + {preset.name} + + + + Mode + + + {modeLabel} + + + + + Preamp + + + {formatGain(preset.preamp)} dB + + + + + Bands + + + {enabledBands}/{preset.bands.length} + + + + + + + Cancel + + + { + onConfirm(); + onClose(); + }} + > + + {confirmLabel} + + + + + ); +} + +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; diff --git a/src/components/eq/EQPresetQrSheet.tsx b/src/components/eq/EQPresetQrSheet.tsx new file mode 100644 index 0000000..5b7e1d9 --- /dev/null +++ b/src/components/eq/EQPresetQrSheet.tsx @@ -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 ( + + + Preset QR + + + + + + {presetName} + + + + Done + + + + ); +} + +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; diff --git a/src/stores/eqStore.ts b/src/stores/eqStore.ts index 08e68b6..22853ca 100644 --- a/src/stores/eqStore.ts +++ b/src/stores/eqStore.ts @@ -379,7 +379,15 @@ export const useEQStore = create((set, get) => { }, 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] }); get().applyPreset(stored.id); },