diff --git a/docs/app-store/CHECKLIST.md b/docs/app-store/CHECKLIST.md new file mode 100644 index 0000000..1ea3307 --- /dev/null +++ b/docs/app-store/CHECKLIST.md @@ -0,0 +1,43 @@ +# Submission checklist + +In order. Steps marked Done are finished. The rest need the owner, and steps 1 to 4 stop a submission today. + +## Before anything else + +1. Decide the fixes in `review-risks.md`. Items 1 to 8 are likely rejections. Keep or rework each effect, then apply the changes. +2. Replace or clear every blocker in `asset-audit.md`. +3. Decide `disableFFmpeg` and `barcodeScannerEnabled` as described in `review-risks.md`. Both are still unset. Either change needs `yarn example prebuild:clean`, a pod install and a new archive. Done on `feat/catalog-redesign`: `main` is merged (Expo 58, React Native 0.88.1, react-native-webgpu 0.17, react-native-audio-api 0.13), the polyfill stays, and all iOS config lives in `app.json`. +4. Merge to `main`, so that `docs/privacy-policy.md` and `docs/credits.md` are live at their GitHub URLs. Both return 404 today, so the About links fail until the merge. Open both links in a private window. To use a proper domain or GitHub Pages instead, update the URL in `AboutScreen.tsx` and in `metadata.md`. +5. Done: the About screen links `docs/credits.md`. + +## Confirm + +6. Confirm publishing kustrablazej@gmail.com in the privacy policy and the review notes, or swap in a dedicated address. +7. Have someone qualified read the privacy policy and the licence notes. They are written from the code, and they are not legal advice. +8. Confirm the bundle ID `com.blazejkustra.rneffects` and the display name "Effects". The bundle ID cannot change after the first upload. +9. Check the app icon. The generated AppIcon set holds the 1024 px Ferrofluid capture with no alpha channel. Confirm you like it on a real home screen. + +## Apple Developer account + +10. Check that the Apple Developer Program membership for team 9S2Q77K6U6 is active and that the Paid Apps and Free Apps agreements are accepted. For an individual account, your legal name appears as the seller. +11. EU only: complete the Digital Services Act trader status in App Store Connect, or the app will not be listed in the EU. Trader status publishes your address and phone number, so decide before release. +12. Done: automatic signing registered the App ID and created an App Store profile that expires on 2027-10-30. Signing uses a cloud-managed Apple Distribution certificate, so the Mac keychain has no local Distribution identity. Exports need a working Xcode account session. + +## App Store Connect + +13. Create the app record with the values in `metadata.md`. +14. Fill in Pricing and Availability (free, regions), the App Privacy answers, the age rating answers and the review notes. +15. Set Content Rights to "contains third-party content" with rights to use it. Answer only after step 2. + +## Screenshots + +16. Capture on a real iPhone or simulator, with no dev tooling or status overlays, at 6.9-inch (1320 x 2868) or the size App Store Connect currently asks for. Use up to ten images. +17. Show interactive, original effects: Dandelion, Ferrofluid, Lava Lamp, Hourglass, Bubble Wrap. Leave out anything with another company's look, plus Beer. An app preview video is optional. + +## Build and upload + +18. In `app.json`, keep `version` 1.0.0 and `ios.buildNumber` 1. Raise `buildNumber` for each upload, and raise `version` after each release. +19. Done: the generated project is `example/ios/Effects`, `AppDelegate.swift` has no hardcoded Metro host, and `HERMES_CLI_PATH` resolves inside the worktree. The built bundle is Hermes bytecode 99. The IPA has the expected Info.plist keys, iPhone only, no background modes, and the dark interface style. +20. A signed App Store build exists at `example/ios/build/export/Effects.ipa`, from `example/ios/build/Effects.xcarchive` (Xcode 27.2, build 27B52, iOS 27.2 SDK). It has not been validated by Apple: this Mac has no altool credentials, and step 13 has to come first. Open the archive in Xcode Organizer, run Validate App, then Distribute App to App Store Connect. The `ios/build` folder is git-ignored, and `prebuild --clean` deletes it, so archive again after any clean prebuild. +21. Debug builds hide Release-only failures in this app, so never rely on them. Every route passed in a Release build on an iOS 26.5 simulator, including microphone, camera and motion refusal. Still to do on a real iPhone: first launch, all effects, real microphone, camera and sensors, Liquid Glass in the top bar, airplane mode, and the About links after step 4. A development-signed copy of the archive is installed on the iPhone 16. +22. Select the build, then Add for Review, and submit. Choose manual release if you want control of the launch date. diff --git a/docs/app-store/asset-audit.md b/docs/app-store/asset-audit.md new file mode 100644 index 0000000..4a808b6 --- /dev/null +++ b/docs/app-store/asset-audit.md @@ -0,0 +1,40 @@ +# Asset provenance audit + +Audited 2026-10-30 from git history, source comments, project notes, EXIF and the Wikimedia Commons API. Paths are relative to `example/assets`. There are no bundled sound files or fonts: every sound is synthesized in code, and text uses system fonts. + +## Blockers: licence incompatible, unknown or third-party owned + +Each of these ships in the app bundle. Replace it or get written permission before submitting. + +| Asset | Used by | What the record shows | Fix | +| --- | --- | --- | --- | +| `foam.jpg` | Beer, Foam lab | Stock photo supplied by the owner, licence never checked (the code says so too) | Find the source and license it, or shoot one | +| `candle/cake.jpg` | Birthday Candle | Cake photo pasted by the owner, then retouched. Source unknown | Find the source, or replace | +| `namedrop/home-atlas.jpg` | NameDrop | Real iOS home screen capture showing Apple app icons and wallpaper | Replace with an invented home screen | +| `iphone-background.png` | Siri Orb | Real iOS home screen capture showing Apple app icons and wallpaper | Replace with an invented home screen | +| `telegram/pattern.png` | Telegram screens | A real Telegram chat wallpaper | Draw an original pattern | +| `telegram/*.png` icons | Telegram screens | Cropped from a screen recording of Telegram | Redraw or use an MIT icon set | +| `instagram/*.png` icons | Voice Delete | Cropped from a recording of Instagram | Redraw or use an MIT icon set | +| `telegram/avatar.jpg` | Telegram screens | Identifiable man speaking on stage. No record of who took it or who he is | Confirm you own it and have consent, or swap for a credited or generated avatar | +| `weather/*.png` | Weather | No source recorded in git or notes. They look like an existing weather icon set | Identify the set, or redraw | +| `catalog/*.jpg` showing other products | Catalog thumbnails | App screenshots that show third-party marks: `holo-card.jpg` (Revolut, Monzo), `wallet.jpg` (Mastercard-style logo), `siri-orb.jpg` (Safari and Messages icons), `banners.jpg` (Duolingo), `imessage.jpg`, `telegram-*.jpg`, `voice-delete.jpg`, and `weather.jpg` | Retake after the matching screens change | + +Picsum images load at runtime in Voice Delete. Lorem Picsum draws from Unsplash photographs, and the owner has no record of which ones, so bundling known images is the safer route. + +## Needs care + +- **SF Symbols.** `cracks/*-fill.png`, `flashlight/camera.png`, `flashlight/flashlight-off.png`, `flashlight/torch-glyph.png` and `imessage/video.png` are SF Symbols rendered to PNG (confirmed in code for the FaceTime glyph, inferred from file names for the rest). Apple's terms allow them in apps for Apple platforms and bar use in logos or app icons. Keep them out of the Android build and out of the store icon. +- **`polaroid/table.jpg` source comment is wrong.** `PolaroidScreen.tsx` credits "Weatherworn top of wooden table" (CC0), but the file's EXIF names Martin Michael Lorenz and it is visually identical to `bubblewrap/walnut.jpg`, so it is CC BY-SA 3.0. `docs/credits.md` has the correct credit. +- **Portrait release.** `namedrop/avatar.jpg` and Black Rock beach show real people, and Commons flags personality rights on the beach photo. CC licences cover copyright only. The avatar appears as a fictional contact called Maren, so leave it out of store screenshots and marketing, where it could read as an endorsement. +- **ShareAlike.** The BY-SA images are adapted, so the adapted files must also be available under BY-SA. This repository is public, which works, as long as those files stay in it. The MIT license of the code does not extend to them. `docs/credits.md` states this. +- **DRM clause.** CC licences restrict adding technical measures to licensed works, and Apple's FairPlay wraps the app binary. Parallel distribution of the same images in the public repository is the usual mitigation. CC0 or CC BY 4.0 replacements remove the question. + +## Clean + +Verified against the Commons API on 2026-10-30, author and licence match `docs/credits.md`: `namedrop/avatar.jpg`, `flashlight/corridor.jpg`, `polaroid/photo-*.jpg`, `polaroid/table.jpg` and `bubblewrap/walnut.jpg` (Lorenz), `dandelion/dandelion-atlas.jpg`, `hourglass/hourglass-atlas.jpg`, `dispersion/cat-atlas.png`, `lavalamp/lava-lamp.jpg`, `cracks/palms.jpg`, `cracks/swing.jpg`, `ferrofluid/marble.jpg`. + +Original to the project: `icon.png`, `splash-icon.png`, `adaptive-icon.png`, `favicon.png` (present from the initial commit, please confirm you made them), and the other `catalog/*.jpg` thumbnails, which are screenshots of this app. + +## In the repository but not shipped + +Nothing requires these, so Metro leaves them out: `charizard.png` (a scan of a Pokémon card, owned by Nintendo, Creatures and GAME FREAK). Delete `charizard.png` from the public repository regardless. diff --git a/docs/app-store/metadata.md b/docs/app-store/metadata.md new file mode 100644 index 0000000..2c88b52 --- /dev/null +++ b/docs/app-store/metadata.md @@ -0,0 +1,125 @@ +# App Store metadata + +Paste these into App Store Connect. Limits are noted where Apple enforces them. Items marked DECISION depend on the owner's choices in `review-risks.md`. + +## Identity + +| Field | Value | +| --- | --- | +| App name (30) | Effects | +| Subtitle (30) | Shaders you tilt, blow & shake | +| Bundle ID | com.blazejkustra.rneffects | +| SKU | rneffects-ios | +| Version | 1.0.0 | +| Build | 1 | +| Primary language | English (U.S.) | +| Primary category | Entertainment | +| Secondary category | Graphics & Design | +| Price | Free, no in-app purchases | +| Devices | iPhone only | +| Copyright | 2026 Blazej Kustra | + +"Effects" is the store name and matches the name under the icon. Check availability in App Store Connect before the first upload; if it is taken, use a subtitle-style fallback such as "Effects: Shader Toys". "React Native Effects" carries a trademark risk (see `review-risks.md`). + +## URLs + +| Field | Value | +| --- | --- | +| Privacy Policy URL | https://github.com/blazejkustra/react-native-effects/blob/main/docs/privacy-policy.md | +| Support URL | https://github.com/blazejkustra/react-native-effects/issues | +| Marketing URL (optional) | https://github.com/blazejkustra/react-native-effects | + +The privacy URL resolves only after `docs/privacy-policy.md` reaches `main`. The About screen links to the same URL. + +## Promotional text (170) + +Dozens of live GPU effects you can touch. Crack the glass, blow out a candle, pour a drink, stir a lava lamp. Every frame renders in real time on your iPhone. + +## Description (DECISION: keep every named effect in sync with the final catalog) + +Effects is a pocket collection of interactive visual effects. Every one is a real-time shader running on your iPhone's GPU, and every one responds to you: touch, tilt, sound and motion. + +Things to try: + +- Tap hard and watch the screen glass crack where you hit it +- Blow on your phone to send dandelion seeds drifting or put out a candle +- Tilt to stir a lava lamp and slosh a full glass +- Hold a magnet near your phone and watch ferrofluid reach for it +- Shake an instant print while it develops +- Scratch foil, pop bubble wrap, flip an hourglass + +There are also fabrics, liquid metal, northern lights, fire, ink and weather skies to play with, plus chat animations that turn messages to dust. + +The app has no accounts, no ads and no tracking. It is built on react-native-effects, an open-source library, and the source code is on GitHub. + +Sensor and microphone readings are used live for the animation and stay on your iPhone. The cracks, chats and payment scenes are visual effects only: nothing happens to your device, and no messages or payments are sent. + +## Keywords (100) + +shader,gpu,visual,interactive,physics,particles,webgpu,generative,satisfying,fidget,art,animation + +Leave out every third-party name, including Apple, iPhone, Instagram, Telegram and Polaroid. + +## Age rating questionnaire + +Apple's current questionnaire. Answers reflect the catalog as of 2026-10-30. + +| Question | Answer | Reason | +| --- | --- | --- | +| Parental controls | No | | +| Age assurance | No | | +| Unrestricted web access | No | About links open in the system browser | +| User-generated content | No | Camera prints stay on device and are never shared | +| Messaging and chat | No | Chat screens are scripted animations | +| Advertising | No | | +| Social media or social feed | No | | +| Profanity or crude humor | None | | +| Horror or fear themes | None | | +| Alcohol, tobacco, or drug use or references | Infrequent | Beer and Lighter effects | +| Medical or treatment information | None | | +| Health or wellness topics | No | | +| Mature or suggestive themes | None | | +| Sexual content or nudity | None | | +| Cartoon or fantasy violence | None | | +| Realistic violence | None | | +| Guns or other weapons | None | | +| Gambling | No | No real money | +| Simulated gambling | Infrequent | Scratch-off card with dollar prizes | +| Contests | None | | +| Loot boxes | No | | + +Expected result: 13+. Apple maps Frequent alcohol or tobacco references to 18+, and two effects out of more than forty supports Infrequent. Apple lists Infrequent simulated gambling as R 18+ in Australia, so see the Scratch-off entry in `review-risks.md`. Confirm the outcome on the Age Ratings page in App Store Connect. + +Do not tick "Made for Kids". + +## App Privacy (nutrition label) + +- Do you or your third-party partners collect data from this app? **No, data not collected.** +- Tracking: **No.** +- Resulting label: **Data Not Collected.** + +Why this holds: nothing the app reads leaves the device and there is no analytics, crash SDK, account or backend. Microphone and sensor data is analysed in memory, photos stay in temporary storage, and Apple defines collection as data transmitted off the device and kept beyond the real-time request. + +One caveat: Voice Delete fetches four images from picsum.photos, so that service sees the user's IP address. The developer never receives it, which keeps the answer accurate, and bundling those images removes the caveat altogether. + +## Export compliance + +`ITSAppUsesNonExemptEncryption` is `false` in `example/app.json`, so App Store Connect will skip the encryption question. + +Basis: the app contains no proprietary or non-standard encryption and only uses HTTPS through iOS. The FFmpeg libraries inside react-native-audio-api include OpenSSL code, and the app never calls it. Setting `disableFFmpeg` removes that code entirely (see the decision in `review-risks.md`). The owner is the party certifying this to Apple. + +## Review notes + +Paste into App Review Information > Notes. No demo account is needed. + + Effects is a catalog of interactive GPU shader effects built with the open-source react-native-effects library. Each effect is an original real-time shader written for this app. There is no login, no backend, no purchases and no user-generated content. + + Everything shaped like another product is a visual demonstration running inside this app. The chat, lock screen, payment and contact screens are scripted animations with invented content: they send no messages, make no payments, and exchange no contacts. The cracked screen effects are clearly part of the app and end when you go back. + + Device features to test: a real iPhone is recommended, since the simulator has no motion sensors. + - Microphone: open Dandelion or Birthday Candle and blow on the phone. AI Orb and AI Voice start on tap. Audio is analysed live and is never recorded. + - Camera: open Polaroid and allow the camera, then press the shutter. The photo stays in temporary storage. + - Motion: tilt in Beer, Hourglass or Lava Lamp. Shake in Polaroid or Magic 8-Ball. + - Magnetometer: hold a magnet near the phone in Ferrofluid. + + Contact: kustrablazej@gmail.com diff --git a/docs/app-store/review-risks.md b/docs/app-store/review-risks.md new file mode 100644 index 0000000..79d6da2 --- /dev/null +++ b/docs/app-store/review-risks.md @@ -0,0 +1,127 @@ +# App Review risk audit + +Ranked by likelihood of rejection or legal complaint, based on the code on 2026-10-30 and the App Review Guidelines current on that date. Likelihood is judgement: App Review varies by reviewer. No effect has been removed or renamed. Every item below is an owner decision unless it says "Applied". + +Rules cited most: 1.1.6 (false features), 1.4.3 (alcohol), 2.5.4 (background modes), 4.1 (copycats), 4.2 and 4.3 (minimum functionality, spam), 5.1.1 (privacy), 5.2.1 (third-party IP), 5.2.5 (Apple products). + +The biggest overall pattern: the app's most striking screens are faithful replicas of Apple, Telegram and Instagram interfaces, with those names on them. A reviewer who opens the catalog sees those names first. The smallest change that removes most of the risk is a rename and restyle pass across the top section below. + +## High: likely rejection + +### 1. Wallet (Apple Pay sheet replica) +Evidence: `WalletPayScreen.tsx` reproduces the double-click-to-pay flow with "Double Click to Pay", "Pay with Face ID", "Hold Near Reader", a stack of cards, and `VISA` text marks. `CardArt.tsx` draws overlapping red and yellow discs in the Mastercard mark, with a chip and contactless symbol. +Guidelines: 5.2.5 (confusingly similar to an Apple interface), 5.2.1 (Apple Pay, Visa and Mastercard marks), 1.1.6 (a payment interface that performs no payment). +Fix: drop every system phrase and the side-button prompt ("Tap to reveal your card"), replace VISA and the discs with an invented mark, and use the app's own sheet design with rounded corners and colours that differ from Wallet. Keep the card glint shader, which is the real effect. + +### 2. Real iOS home screens (NameDrop, Siri Orb) +Evidence: `namedrop/home-atlas.jpg` and `iphone-background.png` are screenshots of iOS with Apple's icons for Maps, Photos, Wallet, Safari, Messages and others, plus Apple's wallpaper. +Guidelines: 5.2.1, 5.2.5. Also an asset blocker. +Fix: rebuild both from invented icons on a CC0 wallpaper. + +### 3. Telegram Dissolve and Telegram Spoiler +Evidence: full chat replicas built from glyphs cropped from Telegram, Telegram's own wallpaper, and "Telegram" in the catalog titles. Copy mentions "t.me/title", "Up to 200,000 members" and the group creation banner naming Blazej Kustra. +Guidelines: 4.1(a) and (c), 5.2.1. +Fix: rename to "Message Dissolve" and "Hidden Text", redraw the icons, replace the wallpaper, and use an invented chat style. The particle work carries the effect on its own. + +### 4. Voice Delete (Instagram DM) +Evidence: Instagram DM layout and glyphs cropped from the app, and "Instagram DM" in the catalog description. +Guidelines: 4.1, 5.2.1. +Fix: remove the name, redraw the icons, restyle the chat. Bundle the four picsum.photos images too, since the screen currently depends on the network and on photos with no recorded source, and it breaks offline during review. + +### 5. iMessage Effects +Evidence: "iMessage Effects" title, a Messages-style chat, the FaceTime SF Symbol, and blue and grey bubbles. +Guidelines: 5.2.5 names Messages as an example of an app you may not look like. +Fix: rename "Screen Effects" or "Send Effects", change the bubble colours and remove the FaceTime button. + +### 6. NameDrop +Evidence: Apple's feature name as the title, "Bring two iPhones together and swap contacts" as the description, and "Share" and "Receive Only" as in iOS, ending in "Contacts exchanged". Nothing is exchanged. +Guidelines: 5.2.5, 5.2.1, 1.1.6. +Fix: call it "Wave Reveal" or similar, describe it as an animation, and remove the "exchanged" outcome. + +### 7. Cracked Screen and Crackgate (fake lock screen, broken-device prank) +Evidence: `LockScreen.tsx` draws a live iOS lock screen with SF Symbols, and a fake notification reads "Did you drop your phone again?". Crackgate names "the iPhone Duo hinge crack, white pixels and all" and adds dead-pixel failures. +Guidelines: 1.1.6 ("trick/joke functionality... inaccurate device data", and it states that "for entertainment purposes" does not help), 5.2.5, 5.2.1. Crackgate also mocks a named Apple product defect, which invites more scrutiny than a generic effect. +Fix: keep the app's chrome visible, or show a persistent small caption, so nobody can read it as their real phone. Replace the lock screen with an abstract scene (a photo with an invented clock style). Rename Crackgate to "Hinge Crack" and remove "iPhone Duo". Applied: the review note states that the effect is contained in the app. + +### 8. Thanos +Evidence: Marvel character name as a title. The redesigned thumbnail is an app screenshot and the film still is gone. +Guidelines: 5.2.1. +Fix: rename to "Snap" or "Dust". + +## Medium: real chance of rejection or complaint + +### 9. Weather App +Evidence: catalog description "Apple Weather UI clone", and the layout follows Apple Weather. +Guidelines: 5.2.5, 1.1.6 (static sample forecast can read as real data). +Fix: rewrite the description to "Skies that change with the weather", state that the data is sample data, and shift the layout. Identify or replace the icons. + +### 10. Siri Orb, AI Orb description, Banners +Evidence: "Siri Orb" title, "Gemini-style" in the AI Orb description, and "Duolingo · Don't lose your streak" as a default in `StreakFire.tsx`, which also imitates iOS notifications. +Guidelines: 5.2.1, 4.1(c), 2.3.7 for metadata. +Fix: "Glow Orb", "Morphing thinking blob", and an invented app name in the banner. + +### 11. Cards +Evidence: `HoloCardScreen.tsx` prints REVOLUT and MONZO on cards, with full card-style details. +Guidelines: 5.2.1. +Fix: invented bank names, sample numbers that look obviously fake. + +### 12. Trademarked titles +- **Polaroid:** a registered mark and a recognisable product design, with the rainbow stripe on the camera. Rename "Instant Camera". The permission string already avoids the name (Applied). +- **Magic 8-ball:** Mattel's mark, and the visual is the same object. Rename "Mystic Ball" and change the number. Fortune-telling is also listed in 4.3(b) as a saturated category, though as one effect of many it should pass. +- **Sauron Eye:** Middle-earth Enterprises enforces this actively. Rename "The Eye". + +### 13. Name and 4.2 / 4.3 positioning +Evidence: the catalog feels like a developer showcase. Entries such as "Foam lab" ("Eight foam treatments side by side, pick one"), "Linear Gradient", "Circular Gradient" and "Scroll Reactive" read as a component-library demo. 4.2 asks for lasting entertainment value, and 4.3(b) names flashlight, sound effect and wallpaper apps as saturated. +Fix: hide Foam lab and the plain gradient screens from the store build (Foam lab is a developer tool that also ships an unlicensed photo). Lead with the toy-like effects, and describe the app as an interactive toy, as `metadata.md` does. The new catalog helps here. A reviewer may still consider it a tech demo, and the honest argument is depth: more than forty original real-time interactive shaders. + +### 14. App name +"React Native Effects": React Native is Meta's project name and mark, and 5.2.1 and 4.1(c) apply to third-party product names in an app name. A search of the US store on 2026-10-30 found "Expo & React Native components" by an unrelated developer, so such names do pass, but that gives no permission. I could not find a published Meta policy on this, so treat it as unresolved. No exact collision with "Effects" turned up. Recommended: keep "Effects" as already configured. The owner can also rebrand for a consumer audience, since "RN" means nothing to non-developers. + +## Age-rating items: fine if declared + +### 15. Lighter +Evidence: a realistic lighter with a flame the user strikes and tilts. +Guideline: a neutral physics toy with no brand and no sale should pass, and the rating question must be answered honestly. +Fix: keep the copy neutral. + +### 16. Beer +Evidence: phone as a full glass, "rotate to drink". +Guidelines: 1.4.3 (excessive alcohol) and 4.3(b), which calls drinking games low-value. iBeer-style apps have shipped for years, and a brand-free visual with no drinking game should pass. +Fix: leave it out of screenshots. The foam photo is an asset blocker. + +### 17. Scratch-off (Clover Cash) +Evidence: a fake $5 ticket, "$500" prizes and "You won $500". No money is involved, so 5.3 does not apply. It counts as simulated gambling in the rating questionnaire, and Apple lists that as R 18+ for Australia even at Infrequent, plus a possible 18+ tier elsewhere at Frequent. Also 1.1.6 if a prize reads as real. +Fix: replace dollar amounts and the ticket price with points or symbols ("You found three clovers"). The simulated gambling answer can then become None. This is the cheapest way to keep the wider rating. + +## Low + +### 18. Inside-joke copy and real names +Evidence: "Expo Go is enough for prod, right?", "Expo Enjoyers", "pod install is fixing it as we speak", and real first names (Kacper, Mateusz, plus Blazej Kustra in a banner). Reviewers will shrug at the jokes, but ordinary users will not understand them. Get consent from anyone named, or use invented names. The copy was written on purpose for developers, so keep it if that stays the audience. + +### 19. Device frames and system-styled fonts +Apple allows device frames in marketing within its guidelines, but avoid iPhone renders in the app's own UI. Text in screenshots must follow 2.3.7, and no iPhone names should appear in the subtitle. + +### 20. Flashlight +"The Dynamic Island torch" is an on-screen effect and leaves the real torch off. 1.1.6 could apply if the screen implies it controls the LED. 4.3(b) lists flashlight apps as saturated, which is harmless for one effect in a larger app. Rewrite as "A beam from the top of your screen". + +### 21. Permissions on entry +Dandelion and Birthday Candle ask for the microphone as soon as they open. Apple accepts this when the purpose is clear, and refusing must leave the app usable. Verified on a simulator: after refusal both screens stay usable and show "Microphone permission denied". + +## Fixed in the repository (Applied) + +| Risk | Change | +| --- | --- | +| 2.5.4 background modes | `UIBackgroundModes: audio` came from the audio plugin default and has no feature behind it. Now disabled | +| 5.1.1 vague purpose strings | Microphone, camera and motion strings are now specific. The motion string was still the template text | +| ITMS-91053 privacy manifest | ExpoFileSystem's own manifest is missing from the built app (its bundle is empty), and its binary uses disk space and file timestamp APIs. Reasons are now declared in the app manifest through `app.json` | +| Release launch failure | Bundle Mode needs `require.getModules`, which Expo defines only in dev builds, so every Release build was a blank screen. Added `example/metro-get-modules-polyfill.js`, wired in `metro.config.js` | +| Dev hooks | Every `__*` window hook was already behind `__DEV__`, and a search of the Release Hermes bundle confirms none ship. No edit needed | +| Dev console | Removed a stray render `console.log` from `ScrollReactiveScreen.tsx` | +| Launch screen | Expo 58 ignores the root `splash` key, which left a launch storyboard pointing at a missing image. The black background and splash image now come from the `expo-splash-screen` plugin | +| iPad | iPhone only, since nothing is designed for iPad. Reviewers may still run it in compatibility mode on an iPad | + +## Other decisions + +- **Portrait lock:** `app.json` now sets portrait, since tilt effects would otherwise rotate the layout mid-gesture. It does change Crackgate: rotating a phone no longer replays it (a tap still does), and the unfolded landscape layout on iPhone Duo was never tested. Check it on a Duo, or remove `orientation` if that effect matters more. +- **FFmpeg:** set `"disableFFmpeg": true` on the react-native-audio-api plugin. The app decodes no audio files, so this should remove LGPL libraries, OpenSSL and about 6.5 MB. It needs `pod install`, so it was not applied or tested. +- **Barcode scanning:** set `"barcodeScannerEnabled": false` on expo-camera. It removes ZXingObjC, which the app never uses. Also needs `pod install`. diff --git a/docs/app-store/terms-of-use.md b/docs/app-store/terms-of-use.md new file mode 100644 index 0000000..8b9e291 --- /dev/null +++ b/docs/app-store/terms-of-use.md @@ -0,0 +1,7 @@ +# Terms of use + +No custom terms are needed for version 1.0.0. The app is free and has no accounts, purchases, subscriptions or user-generated content, so Apple's Standard EULA covers it: + +https://www.apple.com/legal/internet-services/itunes/dev/stdeula/ + +Leave the License Agreement field in App Store Connect on the default. Revisit this if the app gains accounts, purchases or sharing. diff --git a/docs/credits.md b/docs/credits.md new file mode 100644 index 0000000..38cd262 --- /dev/null +++ b/docs/credits.md @@ -0,0 +1,124 @@ +# Effects credits and licenses + +Effects uses the third-party photographs and open-source software listed here. Thank you to their authors. + +The app's own code and the react-native-effects library are MIT licensed, Copyright (c) 2026 Blazej Kustra. See `LICENSE` in this repository. + +## Photographs + +All from Wikimedia Commons. Each is cropped, resized or otherwise adapted for the effect named. These credits imply no endorsement by the authors. Adapted versions of the ShareAlike photos are offered under the same licence as the original, and they are in `example/assets` in this repository. + +| Effect | File | Work | Author | License | +| --- | --- | --- | --- | --- | +| NameDrop | `namedrop/avatar.jpg` | [Smiling senior woman with curly hair portrait](https://commons.wikimedia.org/wiki/File:Smiling_senior_woman_with_curly_hair_portrait.jpg), cropped to the face | Shixart1985 | [CC BY 2.0](https://creativecommons.org/licenses/by/2.0/) | +| Flashlight | `flashlight/corridor.jpg` | [Grungy corridor of abandoned school](https://commons.wikimedia.org/wiki/File:Grungy_corridor_of_abandoned_school.jpg) | Shixart1985 | [CC BY 2.0](https://creativecommons.org/licenses/by/2.0/) | +| Polaroid | `polaroid/photo-beach.jpg` | [Black Rock beach](https://commons.wikimedia.org/wiki/File:Black_Rock_beach.jpg) | Carey Ciuro | [CC BY 2.0](https://creativecommons.org/licenses/by/2.0/) | +| Polaroid | `polaroid/photo-lake.jpg` | [Evening on Kathleen Lake](https://commons.wikimedia.org/wiki/File:Evening_on_Kathleen_Lake.jpg) | Jakub Fryš | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) | +| Polaroid | `polaroid/photo-mountains.jpg` | [Mountain lake, Mylgval mountain cirque before sunset, Caucasus Mountains](https://commons.wikimedia.org/wiki/File:Mountain_lake,_Mylgval_mountain_cirque_before_sunset,_Caucasus_Mountains.jpg) | Vyacheslav Argenberg | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | +| Polaroid, Bubble Wrap | `polaroid/table.jpg`, `bubblewrap/walnut.jpg` | [Oberfläche Wohnzimmertisch](https://commons.wikimedia.org/wiki/File:Oberfl%C3%A4che_Wohnzimmertisch.JPG) | Martin Lorenz | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) | +| Dandelion | `dandelion/dandelion-atlas.jpg` | [Taraxacum seedhead Paslieres 2013-05-09 n01](https://commons.wikimedia.org/wiki/File:Taraxacum_seedhead_Paslieres_2013-05-09_n01.jpg), extended and masked | Marie-Lan Nguyen | [CC BY 2.5](https://creativecommons.org/licenses/by/2.5/) | +| Hourglass | `hourglass/hourglass-atlas.jpg` | [Wooden hourglass 3](https://commons.wikimedia.org/wiki/File:Wooden_hourglass_3.jpg), sand painted out and masked | S Sepp | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) | +| Particle Dispersion | `dispersion/cat-atlas.png` | [Orange tabby cat sitting on fallen leaves](https://commons.wikimedia.org/wiki/File:Orange_tabby_cat_sitting_on_fallen_leaves-Hisashi-01.jpg), masked | Hisashi | [CC BY-SA 2.0](https://creativecommons.org/licenses/by-sa/2.0/) | +| Lava Lamp | `lavalamp/lava-lamp.jpg` | [Lava lamp (yellow)](https://commons.wikimedia.org/wiki/File:Lava_lamp_(yellow).jpg) | DMGualtieri | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) | +| Crackgate | `cracks/palms.jpg` | [Palm silhouettes](https://commons.wikimedia.org/wiki/File:Palm_silhouettes.jpg) | Prakash G (Prakash Srivaths) | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) | +| Cracked Screen | `cracks/swing.jpg` | [Seaside swing at dusk](https://commons.wikimedia.org/wiki/File:Seaside_swing_at_dusk_(Unsplash).jpg) | Georg Nietsch | [CC0](https://creativecommons.org/publicdomain/zero/1.0/) | +| Ferrofluid | `ferrofluid/marble.jpg` | [Marble Kymi Euboea Greece](https://commons.wikimedia.org/wiki/File:Marble_Kymi_Euboea_Greece.jpg) | Jebulon | [CC0](https://creativecommons.org/publicdomain/zero/1.0/) | + +Lorem Picsum (picsum.photos) serves the four placeholder pictures in the Voice Delete effect at runtime. + +## Native software + +| Component | Used through | License | +| --- | --- | --- | +| FFmpeg 8.0.1 (libavcodec, libavformat, libavutil, libswresample) | react-native-audio-api, as separate dynamic frameworks | LGPL 2.1 or later. Source: https://ffmpeg.org. Build scripts: https://github.com/software-mansion-labs/rn-audio-libs | +| OpenSSL | statically inside the FFmpeg frameworks | Apache-2.0 | +| Dawn (WebGPU) | react-native-webgpu | BSD-3-Clause | +| miniaudio | react-native-audio-api | MIT-0 or public domain | +| PFFFT | react-native-audio-api | BSD-style | +| Signalsmith Stretch | react-native-audio-api | MIT | +| Hermes, Yoga, React Native | react-native | MIT | +| Folly | react-native | Apache-2.0 | +| Boost | react-native | BSL-1.0 | +| glog | react-native | BSD-3-Clause | +| double-conversion | react-native | BSD-3-Clause | +| fmt | react-native | MIT | +| ZXingObjC | expo-camera barcode scanning | Apache-2.0 | +| Expo modules | expo | MIT | + +## JavaScript packages in the app bundle + +Taken from the production bundle's source map. MIT and ISC packages are distributed under their own license text, in each package's `LICENSE` file. The MIT License permission notice is reproduced below. + +| Package | Version | License | Copyright | +| --- | --- | --- | --- | +| @babel/runtime | 7.29.7 | MIT | Copyright (c) 2014-present Sebastian McKenzie and other contributors | +| @expo/cli | 56.1.14 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| @react-native/assets-registry | 0.85.3 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates | +| @react-native/js-polyfills | 0.85.3 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates | +| @react-native/normalize-colors | 0.85.3 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates | +| @react-native/virtualized-lists | 0.85.3 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates | +| @react-navigation/core | 7.17.5 | MIT | Copyright (c) 2017 React Navigation Contributors | +| @react-navigation/elements | 2.9.19 | MIT | Copyright (c) 2017 React Navigation Contributors | +| @react-navigation/native | 7.2.5 | MIT | Copyright (c) 2017 React Navigation Contributors | +| @react-navigation/native-stack | 7.16.0 | MIT | Copyright (c) 2017 React Navigation Contributors | +| @react-navigation/routers | 7.5.5 | MIT | Copyright (c) 2017 React Navigation Contributors | +| @ungap/structured-clone | 1.3.0 | ISC | Copyright (c) 2021, Andrea Giammarchi, @WebReflection | +| abort-controller | 3.0.0 | MIT | Copyright (c) 2017 Toru Nagashima | +| base64-js | 1.5.1 | MIT | Copyright (c) 2014 Jameson Little | +| color | 4.2.3 | MIT | Copyright (c) 2012 Heather Arthur | +| color-convert | 2.0.1 | MIT | Copyright (c) 2011-2016 Heather Arthur | +| color-name | 1.1.4 | MIT | Copyright (c) 2015 Dmitry Ivanov | +| color-string | 1.9.1 | MIT | Copyright (c) 2011 Heather Arthur | +| decode-uri-component | 0.2.2 | MIT | Copyright (c) 2017, Sam Verschueren (github.com/SamVerschueren) | +| escape-string-regexp | 4.0.0 | MIT | Copyright (c) Sindre Sorhus (https://sindresorhus.com) | +| event-target-shim | 5.0.1 | MIT | Copyright (c) 2015 Toru Nagashima | +| expo | 56.0.9 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| expo-asset | 56.0.16 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| expo-camera | 56.0.8 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| expo-constants | 56.0.17 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| expo-modules-core | 56.0.15 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| expo-sensors | 56.0.5 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| fast-deep-equal | 3.1.3 | MIT | Copyright (c) 2017 Evgeny Poberezkin | +| filter-obj | 1.1.0 | MIT | Copyright (c) Sindre Sorhus (sindresorhus.com) | +| flow-enums-runtime | 0.0.6 | MIT | Copyright (c) Facebook, Inc. and its affiliates. | +| hoist-non-react-statics | 3.3.2 | BSD-3-Clause | Copyright (c) 2015, Yahoo! Inc. All rights reserved. | +| invariant | 2.2.4 | MIT | Copyright (c) 2013-present, Facebook, Inc. | +| is-arrayish | 0.3.4 | MIT | Copyright (c) 2015 JD Ballard | +| memoize-one | 5.2.1 | MIT | Copyright (c) 2019 Alexander Reardon | +| nanoid | 3.3.12 | MIT | Copyright 2017 Andrey Sitnik | +| nullthrows | 1.1.1 | MIT | Copyright (c) 2016 Andres Suarez | +| promise | 8.3.0 | MIT | Copyright (c) 2014 Forbes Lindesay | +| query-string | 7.1.3 | MIT | Copyright (c) Sindre Sorhus (http://sindresorhus.com) | +| react | 19.2.3 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | +| react-freeze | 1.0.4 | MIT | Copyright (c) 2021 Software Mansion | +| react-is | 18.3.1 | MIT | Copyright (c) Facebook, Inc. and its affiliates. | +| react-native | 0.85.3 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | +| react-native-audio-api | 0.12.2 | MIT | Copyright (c) Software Mansion | +| react-native-gesture-handler | 2.31.2 | MIT | Copyright (c) 2016 Software Mansion | +| react-native-is-edge-to-edge | 1.3.1 | MIT | Copyright (c) 2024 Mathieu Acthernoene | +| react-native-reanimated | 4.3.1 | MIT | Copyright (c) 2016 Software Mansion | +| react-native-safe-area-context | 5.7.0 | MIT | Copyright (c) 2019 Th3rd Wave | +| react-native-screens | 4.25.2 | MIT | Copyright (c) 2018 Software Mansion | +| react-native-view-shot | 5.1.0 | MIT | Copyright (c) 2016 Gaëtan Renaudeau | +| react-native-webgpu | 0.5.15 | MIT | Copyright (c) 2024 William Candillon | +| react-native-worklets | 0.8.3 | MIT | Copyright (c) 2024 nobody | +| regenerator-runtime | 0.13.11 | MIT | Copyright (c) 2014-present, Facebook, Inc. | +| scheduler | 0.27.0 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | +| semver | 7.8.2 | ISC | Copyright (c) Isaac Z. Schlueter and Contributors | +| simple-swizzle | 0.2.4 | MIT | Copyright (c) 2015 Josh Junon | +| split-on-first | 1.1.0 | MIT | Copyright (c) Sindre Sorhus (sindresorhus.com) | +| stacktrace-parser | 0.1.11 | MIT | Copyright (c) 2014-2019 Georg Tavonius | +| strict-uri-encode | 2.0.0 | MIT | Copyright (c) Kevin Martensson (github.com/kevva) | +| use-latest-callback | 0.2.6 | MIT | Copyright (c) 2023 Satyajit Sahoo | +| use-sync-external-store | 1.6.0 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | +| warn-once | 0.1.1 | MIT | Copyright (c) 2022 Satyajit Sahoo | +| whatwg-fetch | 3.6.20 | MIT | Copyright (c) 2014-2023 GitHub, Inc. | +| whatwg-url-minimum | 0.1.2 | MIT | Copyright (c) Phil Pluckthun, | + +## MIT License text + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md new file mode 100644 index 0000000..7dcb2b0 --- /dev/null +++ b/docs/privacy-policy.md @@ -0,0 +1,65 @@ +# Effects Privacy Policy + +Effective date: 2026-10-30 + +Effects is a free iPhone app that shows interactive visual effects. It is published by Blazej Kustra, an individual developer ("I" in this policy). This policy explains what the app does with your data. + +## Summary + +Effects has no accounts, no ads, no analytics and no crash reporting SDK. I do not collect, receive, store, sell or share your personal data. Your photos, audio and sensor readings stay on your iPhone. + +## Permissions the app asks for + +You can change each of these at any time in the iOS Settings app. Refusing a permission only affects the effects that use it. + +### Microphone + +The Dandelion and Birthday Candle effects use the microphone so you can blow on the screen. The AI Orb and AI Voice effects use it when you tap to start listening, so the visuals follow your voice. + +The app reads the sound level and frequency of the live signal to drive the animation. Audio is never recorded, saved or transmitted. The microphone stops when you stop listening or leave the effect. + +### Camera + +The Polaroid effect uses the back camera so you can take a photo and watch it develop as an instant print. You have to allow the camera and press the shutter. + +The photo is held in the app's temporary storage on your iPhone so it can be shown on screen. It is never uploaded and is never added to your photo library. iOS clears this temporary storage over time, and deleting the app removes it. + +### Motion and magnetic sensors + +Many effects use the accelerometer and device motion so they respond when you tilt, shake or flip your iPhone. The Ferrofluid effect uses the magnetometer so it responds to a magnet held near the phone. Readings are used live for the animation, and are never stored or transmitted. + +## On-screen snapshots + +Some effects take a picture of their own screen content to animate it, for example a chat bubble that turns to dust. These images are kept in temporary storage on your iPhone while the animation runs and are never transmitted. + +## Network use + +The app works without an account and sends none of your data to me. There is one network feature: + +- The Voice Delete effect downloads four placeholder pictures from the public image service Lorem Picsum (picsum.photos). As with any web request, that service and its hosting providers receive your IP address and basic request details. I do not receive this information and the request contains nothing else about you. Lorem Picsum's own terms and privacy practices apply to it. + +Links on the About screen open in your browser, where the website's own privacy policy applies. + +## Apple + +Apple distributes the app. Apple may give me aggregated, anonymous statistics and crash reports if you agreed to share them with app developers in your iOS settings. These reports do not identify you to me. Apple's privacy policy applies to that processing. + +## Data retention and deletion + +I hold no data about you, so there is nothing for me to retain or delete. To remove everything the app has stored on your iPhone, delete the app. To withdraw a permission, turn it off in the iOS Settings app. + +## Your rights + +Depending on where you live, laws such as the EU and UK GDPR give you rights over your personal data, including access, correction and deletion. Since I hold no personal data through the app, a request will usually have nothing to act on, but you are welcome to contact me. If you email me, I use your message and address only to reply, and I delete them when the conversation is finished. You can also complain to your local data protection authority. + +## Children + +The app does not knowingly collect data from anyone, including children under 13. + +## Changes + +If the app's data practices change, I will update this page and its effective date before releasing that version. + +## Contact + +Blazej Kustra, kustrablazej@gmail.com diff --git a/example/app.json b/example/app.json index 1cf3411..5079e0f 100644 --- a/example/app.json +++ b/example/app.json @@ -1,18 +1,53 @@ { "expo": { - "name": "RN Effects", + "name": "Effects", "slug": "react-native-effects-example", "version": "1.0.0", + "orientation": "portrait", + "userInterfaceStyle": "dark", "scheme": "react-native-effects-example", "icon": "./assets/icon.png", - "splash": { - "image": "./assets/splash-icon.png", - "resizeMode": "contain", - "backgroundColor": "#000000" - }, "ios": { "bundleIdentifier": "com.blazejkustra.rneffects", - "supportsTablet": true + "buildNumber": "1", + "supportsTablet": false, + "config": { + "usesNonExemptEncryption": false + }, + "privacyManifests": { + "NSPrivacyTracking": false, + "NSPrivacyCollectedDataTypes": [], + "NSPrivacyAccessedAPITypes": [ + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults", + "NSPrivacyAccessedAPITypeReasons": [ + "CA92.1" + ] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp", + "NSPrivacyAccessedAPITypeReasons": [ + "C617.1", + "0A2A.1", + "3B52.1" + ] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime", + "NSPrivacyAccessedAPITypeReasons": [ + "35F9.1" + ] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace", + "NSPrivacyAccessedAPITypeReasons": [ + "E174.1", + "85F4.1" + ] + } + ] + }, + "appleTeamId": "9S2Q77K6U6" }, "android": { "adaptiveIcon": { @@ -33,7 +68,30 @@ [ "react-native-audio-api", { - "iosMicrophonePermission": "RN Effects uses the microphone so the shaders react to your voice." + "iosMicrophonePermission": "Effects listens to the microphone so effects can react to your voice and to you blowing on the screen. Audio is analysed live on your iPhone and is never recorded or sent anywhere.", + "iosBackgroundMode": false + } + ], + [ + "expo-camera", + { + "cameraPermission": "Effects uses the camera to take a photo for the instant camera effect. The photo stays on your iPhone and is never uploaded.", + "microphonePermission": "Effects listens to the microphone so effects can react to your voice and to you blowing on the screen. Audio is analysed live on your iPhone and is never recorded or sent anywhere." + } + ], + [ + "expo-sensors", + { + "motionPermission": "Effects reads device motion so effects respond when you tilt, shake or flip your iPhone. Motion data stays on your iPhone." + } + ], + [ + "expo-splash-screen", + { + "image": "./assets/splash-icon.png", + "imageWidth": 300, + "resizeMode": "contain", + "backgroundColor": "#000000" } ] ] diff --git a/example/assets/adaptive-icon.png b/example/assets/adaptive-icon.png index ecbefa4..afa00c5 100644 Binary files a/example/assets/adaptive-icon.png and b/example/assets/adaptive-icon.png differ diff --git a/example/assets/bubblewrap/walnut.jpg b/example/assets/bubblewrap/walnut.jpg new file mode 100644 index 0000000..5bfab2a Binary files /dev/null and b/example/assets/bubblewrap/walnut.jpg differ diff --git a/example/assets/catalog/ai-orb.jpg b/example/assets/catalog/ai-orb.jpg new file mode 100644 index 0000000..a28f775 Binary files /dev/null and b/example/assets/catalog/ai-orb.jpg differ diff --git a/example/assets/catalog/ai-shimmer.jpg b/example/assets/catalog/ai-shimmer.jpg new file mode 100644 index 0000000..73dfd93 Binary files /dev/null and b/example/assets/catalog/ai-shimmer.jpg differ diff --git a/example/assets/catalog/ai-voice.jpg b/example/assets/catalog/ai-voice.jpg new file mode 100644 index 0000000..d007ac4 Binary files /dev/null and b/example/assets/catalog/ai-voice.jpg differ diff --git a/example/assets/catalog/aurora-hero.jpg b/example/assets/catalog/aurora-hero.jpg new file mode 100644 index 0000000..a78426e Binary files /dev/null and b/example/assets/catalog/aurora-hero.jpg differ diff --git a/example/assets/catalog/aurora.jpg b/example/assets/catalog/aurora.jpg new file mode 100644 index 0000000..560baba Binary files /dev/null and b/example/assets/catalog/aurora.jpg differ diff --git a/example/assets/catalog/banners.jpg b/example/assets/catalog/banners.jpg new file mode 100644 index 0000000..f578687 Binary files /dev/null and b/example/assets/catalog/banners.jpg differ diff --git a/example/assets/catalog/beer.jpg b/example/assets/catalog/beer.jpg new file mode 100644 index 0000000..3dd2324 Binary files /dev/null and b/example/assets/catalog/beer.jpg differ diff --git a/example/assets/catalog/birthday-candle-hero.jpg b/example/assets/catalog/birthday-candle-hero.jpg new file mode 100644 index 0000000..eaf0c37 Binary files /dev/null and b/example/assets/catalog/birthday-candle-hero.jpg differ diff --git a/example/assets/catalog/birthday-candle.jpg b/example/assets/catalog/birthday-candle.jpg new file mode 100644 index 0000000..fd610d0 Binary files /dev/null and b/example/assets/catalog/birthday-candle.jpg differ diff --git a/example/assets/catalog/bubble-wrap.jpg b/example/assets/catalog/bubble-wrap.jpg new file mode 100644 index 0000000..85ab170 Binary files /dev/null and b/example/assets/catalog/bubble-wrap.jpg differ diff --git a/example/assets/catalog/calico-swirl.jpg b/example/assets/catalog/calico-swirl.jpg new file mode 100644 index 0000000..8f2218d Binary files /dev/null and b/example/assets/catalog/calico-swirl.jpg differ diff --git a/example/assets/catalog/campfire.jpg b/example/assets/catalog/campfire.jpg new file mode 100644 index 0000000..33ecc53 Binary files /dev/null and b/example/assets/catalog/campfire.jpg differ diff --git a/example/assets/catalog/circular-gradient.jpg b/example/assets/catalog/circular-gradient.jpg new file mode 100644 index 0000000..8bf07c2 Binary files /dev/null and b/example/assets/catalog/circular-gradient.jpg differ diff --git a/example/assets/catalog/cracked-screen-hero.jpg b/example/assets/catalog/cracked-screen-hero.jpg new file mode 100644 index 0000000..44293ea Binary files /dev/null and b/example/assets/catalog/cracked-screen-hero.jpg differ diff --git a/example/assets/catalog/cracked-screen.jpg b/example/assets/catalog/cracked-screen.jpg new file mode 100644 index 0000000..548c3e4 Binary files /dev/null and b/example/assets/catalog/cracked-screen.jpg differ diff --git a/example/assets/catalog/crt-off.jpg b/example/assets/catalog/crt-off.jpg new file mode 100644 index 0000000..35a3c8d Binary files /dev/null and b/example/assets/catalog/crt-off.jpg differ diff --git a/example/assets/catalog/dandelion.jpg b/example/assets/catalog/dandelion.jpg new file mode 100644 index 0000000..008eaad Binary files /dev/null and b/example/assets/catalog/dandelion.jpg differ diff --git a/example/assets/catalog/duo-crackgate.jpg b/example/assets/catalog/duo-crackgate.jpg new file mode 100644 index 0000000..2074552 Binary files /dev/null and b/example/assets/catalog/duo-crackgate.jpg differ diff --git a/example/assets/catalog/ferrofluid.jpg b/example/assets/catalog/ferrofluid.jpg new file mode 100644 index 0000000..2deb427 Binary files /dev/null and b/example/assets/catalog/ferrofluid.jpg differ diff --git a/example/assets/catalog/flashlight.jpg b/example/assets/catalog/flashlight.jpg new file mode 100644 index 0000000..82aee33 Binary files /dev/null and b/example/assets/catalog/flashlight.jpg differ diff --git a/example/assets/catalog/foamlab.jpg b/example/assets/catalog/foamlab.jpg new file mode 100644 index 0000000..fed123e Binary files /dev/null and b/example/assets/catalog/foamlab.jpg differ diff --git a/example/assets/catalog/holo-card.jpg b/example/assets/catalog/holo-card.jpg new file mode 100644 index 0000000..7376c0a Binary files /dev/null and b/example/assets/catalog/holo-card.jpg differ diff --git a/example/assets/catalog/holo-foil.jpg b/example/assets/catalog/holo-foil.jpg new file mode 100644 index 0000000..ef225b8 Binary files /dev/null and b/example/assets/catalog/holo-foil.jpg differ diff --git a/example/assets/catalog/hourglass.jpg b/example/assets/catalog/hourglass.jpg new file mode 100644 index 0000000..41878cf Binary files /dev/null and b/example/assets/catalog/hourglass.jpg differ diff --git a/example/assets/catalog/imessage.jpg b/example/assets/catalog/imessage.jpg new file mode 100644 index 0000000..924604a Binary files /dev/null and b/example/assets/catalog/imessage.jpg differ diff --git a/example/assets/catalog/ink-bloom.jpg b/example/assets/catalog/ink-bloom.jpg new file mode 100644 index 0000000..3c1632a Binary files /dev/null and b/example/assets/catalog/ink-bloom.jpg differ diff --git a/example/assets/catalog/iridescence.jpg b/example/assets/catalog/iridescence.jpg new file mode 100644 index 0000000..9147b08 Binary files /dev/null and b/example/assets/catalog/iridescence.jpg differ diff --git a/example/assets/catalog/lava-lamp-hero.jpg b/example/assets/catalog/lava-lamp-hero.jpg new file mode 100644 index 0000000..e2d7c8b Binary files /dev/null and b/example/assets/catalog/lava-lamp-hero.jpg differ diff --git a/example/assets/catalog/lava-lamp.jpg b/example/assets/catalog/lava-lamp.jpg new file mode 100644 index 0000000..b0e879b Binary files /dev/null and b/example/assets/catalog/lava-lamp.jpg differ diff --git a/example/assets/catalog/lighter-hero.jpg b/example/assets/catalog/lighter-hero.jpg new file mode 100644 index 0000000..44bbaab Binary files /dev/null and b/example/assets/catalog/lighter-hero.jpg differ diff --git a/example/assets/catalog/lighter.jpg b/example/assets/catalog/lighter.jpg new file mode 100644 index 0000000..d51ab7d Binary files /dev/null and b/example/assets/catalog/lighter.jpg differ diff --git a/example/assets/catalog/linear-gradient-animated.jpg b/example/assets/catalog/linear-gradient-animated.jpg new file mode 100644 index 0000000..20f685d Binary files /dev/null and b/example/assets/catalog/linear-gradient-animated.jpg differ diff --git a/example/assets/catalog/linear-gradient.jpg b/example/assets/catalog/linear-gradient.jpg new file mode 100644 index 0000000..ebf0485 Binary files /dev/null and b/example/assets/catalog/linear-gradient.jpg differ diff --git a/example/assets/catalog/liquid-chrome.jpg b/example/assets/catalog/liquid-chrome.jpg new file mode 100644 index 0000000..a61e4f3 Binary files /dev/null and b/example/assets/catalog/liquid-chrome.jpg differ diff --git a/example/assets/catalog/magic-8-ball.jpg b/example/assets/catalog/magic-8-ball.jpg new file mode 100644 index 0000000..e07606f Binary files /dev/null and b/example/assets/catalog/magic-8-ball.jpg differ diff --git a/example/assets/catalog/materials.jpg b/example/assets/catalog/materials.jpg new file mode 100644 index 0000000..62e0739 Binary files /dev/null and b/example/assets/catalog/materials.jpg differ diff --git a/example/assets/catalog/namedrop.jpg b/example/assets/catalog/namedrop.jpg new file mode 100644 index 0000000..69fc692 Binary files /dev/null and b/example/assets/catalog/namedrop.jpg differ diff --git a/example/assets/catalog/particle-dispersion.jpg b/example/assets/catalog/particle-dispersion.jpg new file mode 100644 index 0000000..99716af Binary files /dev/null and b/example/assets/catalog/particle-dispersion.jpg differ diff --git a/example/assets/catalog/polaroid.jpg b/example/assets/catalog/polaroid.jpg new file mode 100644 index 0000000..2556264 Binary files /dev/null and b/example/assets/catalog/polaroid.jpg differ diff --git a/example/assets/catalog/scratch-off.jpg b/example/assets/catalog/scratch-off.jpg new file mode 100644 index 0000000..fa0036b Binary files /dev/null and b/example/assets/catalog/scratch-off.jpg differ diff --git a/example/assets/catalog/scroll-reactive.jpg b/example/assets/catalog/scroll-reactive.jpg new file mode 100644 index 0000000..4524e25 Binary files /dev/null and b/example/assets/catalog/scroll-reactive.jpg differ diff --git a/example/assets/catalog/silk.jpg b/example/assets/catalog/silk.jpg new file mode 100644 index 0000000..9cd3271 Binary files /dev/null and b/example/assets/catalog/silk.jpg differ diff --git a/example/assets/catalog/siri-orb.jpg b/example/assets/catalog/siri-orb.jpg new file mode 100644 index 0000000..a641c72 Binary files /dev/null and b/example/assets/catalog/siri-orb.jpg differ diff --git a/example/assets/catalog/telegram-dissolve.jpg b/example/assets/catalog/telegram-dissolve.jpg new file mode 100644 index 0000000..23dd0ef Binary files /dev/null and b/example/assets/catalog/telegram-dissolve.jpg differ diff --git a/example/assets/catalog/telegram-spoiler.jpg b/example/assets/catalog/telegram-spoiler.jpg new file mode 100644 index 0000000..e041bd9 Binary files /dev/null and b/example/assets/catalog/telegram-spoiler.jpg differ diff --git a/example/assets/catalog/thanos.jpg b/example/assets/catalog/thanos.jpg new file mode 100644 index 0000000..8bdc5b5 Binary files /dev/null and b/example/assets/catalog/thanos.jpg differ diff --git a/example/assets/catalog/touch-field.jpg b/example/assets/catalog/touch-field.jpg new file mode 100644 index 0000000..81cbf54 Binary files /dev/null and b/example/assets/catalog/touch-field.jpg differ diff --git a/example/assets/catalog/voice-delete.jpg b/example/assets/catalog/voice-delete.jpg new file mode 100644 index 0000000..12609f0 Binary files /dev/null and b/example/assets/catalog/voice-delete.jpg differ diff --git a/example/assets/catalog/wallet.jpg b/example/assets/catalog/wallet.jpg new file mode 100644 index 0000000..6524e99 Binary files /dev/null and b/example/assets/catalog/wallet.jpg differ diff --git a/example/assets/catalog/weather.jpg b/example/assets/catalog/weather.jpg new file mode 100644 index 0000000..300cb00 Binary files /dev/null and b/example/assets/catalog/weather.jpg differ diff --git a/example/assets/cigarette.png b/example/assets/cigarette.png deleted file mode 100644 index 9c41e50..0000000 Binary files a/example/assets/cigarette.png and /dev/null differ diff --git a/example/assets/components/ai-orb.png b/example/assets/components/ai-orb.png deleted file mode 100644 index 4bd7433..0000000 Binary files a/example/assets/components/ai-orb.png and /dev/null differ diff --git a/example/assets/components/assistant.png b/example/assets/components/assistant.png deleted file mode 100644 index 160595d..0000000 Binary files a/example/assets/components/assistant.png and /dev/null differ diff --git a/example/assets/components/aurora.png b/example/assets/components/aurora.png deleted file mode 100644 index 6d72a6a..0000000 Binary files a/example/assets/components/aurora.png and /dev/null differ diff --git a/example/assets/components/banners.png b/example/assets/components/banners.png deleted file mode 100644 index d8dab63..0000000 Binary files a/example/assets/components/banners.png and /dev/null differ diff --git a/example/assets/components/beer.png b/example/assets/components/beer.png deleted file mode 100644 index 7234bb8..0000000 Binary files a/example/assets/components/beer.png and /dev/null differ diff --git a/example/assets/components/calico-swirl.png b/example/assets/components/calico-swirl.png deleted file mode 100644 index 7d16185..0000000 Binary files a/example/assets/components/calico-swirl.png and /dev/null differ diff --git a/example/assets/components/campfire.png b/example/assets/components/campfire.png deleted file mode 100644 index 1d496cd..0000000 Binary files a/example/assets/components/campfire.png and /dev/null differ diff --git a/example/assets/components/candle.png b/example/assets/components/candle.png deleted file mode 100644 index c11e7f9..0000000 Binary files a/example/assets/components/candle.png and /dev/null differ diff --git a/example/assets/components/cards.png b/example/assets/components/cards.png deleted file mode 100644 index a6a680c..0000000 Binary files a/example/assets/components/cards.png and /dev/null differ diff --git a/example/assets/components/cigarette.png b/example/assets/components/cigarette.png deleted file mode 100644 index 69c8a50..0000000 Binary files a/example/assets/components/cigarette.png and /dev/null differ diff --git a/example/assets/components/circular-gradient.png b/example/assets/components/circular-gradient.png deleted file mode 100644 index 2d1171f..0000000 Binary files a/example/assets/components/circular-gradient.png and /dev/null differ diff --git a/example/assets/components/dandelion.png b/example/assets/components/dandelion.png deleted file mode 100644 index ed72478..0000000 Binary files a/example/assets/components/dandelion.png and /dev/null differ diff --git a/example/assets/components/desert.png b/example/assets/components/desert.png deleted file mode 100644 index 47a81d9..0000000 Binary files a/example/assets/components/desert.png and /dev/null differ diff --git a/example/assets/components/field.png b/example/assets/components/field.png deleted file mode 100644 index 39dc165..0000000 Binary files a/example/assets/components/field.png and /dev/null differ diff --git a/example/assets/components/glitter.png b/example/assets/components/glitter.png deleted file mode 100644 index 2798860..0000000 Binary files a/example/assets/components/glitter.png and /dev/null differ diff --git a/example/assets/components/holo.png b/example/assets/components/holo.png deleted file mode 100644 index 2edf3d3..0000000 Binary files a/example/assets/components/holo.png and /dev/null differ diff --git a/example/assets/components/hourglass.png b/example/assets/components/hourglass.png deleted file mode 100644 index a8ea509..0000000 Binary files a/example/assets/components/hourglass.png and /dev/null differ diff --git a/example/assets/components/imessage.png b/example/assets/components/imessage.png deleted file mode 100644 index d0ce4d0..0000000 Binary files a/example/assets/components/imessage.png and /dev/null differ diff --git a/example/assets/components/ink.png b/example/assets/components/ink.png deleted file mode 100644 index 8fb3bbb..0000000 Binary files a/example/assets/components/ink.png and /dev/null differ diff --git a/example/assets/components/iridescence.png b/example/assets/components/iridescence.png deleted file mode 100644 index 334b462..0000000 Binary files a/example/assets/components/iridescence.png and /dev/null differ diff --git a/example/assets/components/lighter.png b/example/assets/components/lighter.png deleted file mode 100644 index 18b03a2..0000000 Binary files a/example/assets/components/lighter.png and /dev/null differ diff --git a/example/assets/components/linear-gradient.png b/example/assets/components/linear-gradient.png deleted file mode 100644 index 93282bc..0000000 Binary files a/example/assets/components/linear-gradient.png and /dev/null differ diff --git a/example/assets/components/liquid-chrome.png b/example/assets/components/liquid-chrome.png deleted file mode 100644 index a3dce54..0000000 Binary files a/example/assets/components/liquid-chrome.png and /dev/null differ diff --git a/example/assets/components/magic-8-ball.png b/example/assets/components/magic-8-ball.png deleted file mode 100644 index a8feff7..0000000 Binary files a/example/assets/components/magic-8-ball.png and /dev/null differ diff --git a/example/assets/components/material.png b/example/assets/components/material.png deleted file mode 100644 index dd2a39a..0000000 Binary files a/example/assets/components/material.png and /dev/null differ diff --git a/example/assets/components/particle-dispersion.png b/example/assets/components/particle-dispersion.png deleted file mode 100644 index 18821b5..0000000 Binary files a/example/assets/components/particle-dispersion.png and /dev/null differ diff --git a/example/assets/components/scroll.png b/example/assets/components/scroll.png deleted file mode 100644 index 8448623..0000000 Binary files a/example/assets/components/scroll.png and /dev/null differ diff --git a/example/assets/components/silk.png b/example/assets/components/silk.png deleted file mode 100644 index 002c78c..0000000 Binary files a/example/assets/components/silk.png and /dev/null differ diff --git a/example/assets/components/siri-orb.png b/example/assets/components/siri-orb.png deleted file mode 100644 index b85ba8f..0000000 Binary files a/example/assets/components/siri-orb.png and /dev/null differ diff --git a/example/assets/components/telegram-dissolve.png b/example/assets/components/telegram-dissolve.png deleted file mode 100644 index 8ca2160..0000000 Binary files a/example/assets/components/telegram-dissolve.png and /dev/null differ diff --git a/example/assets/components/telegram-spoiler.png b/example/assets/components/telegram-spoiler.png deleted file mode 100644 index f7a1a6c..0000000 Binary files a/example/assets/components/telegram-spoiler.png and /dev/null differ diff --git a/example/assets/components/thanos.png b/example/assets/components/thanos.png deleted file mode 100644 index c8a1a0e..0000000 Binary files a/example/assets/components/thanos.png and /dev/null differ diff --git a/example/assets/components/voice-delete.png b/example/assets/components/voice-delete.png deleted file mode 100644 index 01bb617..0000000 Binary files a/example/assets/components/voice-delete.png and /dev/null differ diff --git a/example/assets/components/waveform.png b/example/assets/components/waveform.png deleted file mode 100644 index d573732..0000000 Binary files a/example/assets/components/waveform.png and /dev/null differ diff --git a/example/assets/components/weather.png b/example/assets/components/weather.png deleted file mode 100644 index 27ef9c7..0000000 Binary files a/example/assets/components/weather.png and /dev/null differ diff --git a/example/assets/cracks/camera-fill.png b/example/assets/cracks/camera-fill.png new file mode 100644 index 0000000..824eb89 Binary files /dev/null and b/example/assets/cracks/camera-fill.png differ diff --git a/example/assets/cracks/flashlight-off-fill.png b/example/assets/cracks/flashlight-off-fill.png new file mode 100644 index 0000000..6da5922 Binary files /dev/null and b/example/assets/cracks/flashlight-off-fill.png differ diff --git a/example/assets/cracks/lock-fill.png b/example/assets/cracks/lock-fill.png new file mode 100644 index 0000000..59689c9 Binary files /dev/null and b/example/assets/cracks/lock-fill.png differ diff --git a/example/assets/cracks/message-fill.png b/example/assets/cracks/message-fill.png new file mode 100644 index 0000000..2430cc7 Binary files /dev/null and b/example/assets/cracks/message-fill.png differ diff --git a/example/assets/cracks/palms.jpg b/example/assets/cracks/palms.jpg new file mode 100644 index 0000000..569403f Binary files /dev/null and b/example/assets/cracks/palms.jpg differ diff --git a/example/assets/cracks/swing.jpg b/example/assets/cracks/swing.jpg new file mode 100644 index 0000000..8c584f3 Binary files /dev/null and b/example/assets/cracks/swing.jpg differ diff --git a/example/assets/favicon.png b/example/assets/favicon.png index 4570cde..5ebe1ad 100644 Binary files a/example/assets/favicon.png and b/example/assets/favicon.png differ diff --git a/example/assets/ferrofluid/marble.jpg b/example/assets/ferrofluid/marble.jpg new file mode 100644 index 0000000..5ae5dbf Binary files /dev/null and b/example/assets/ferrofluid/marble.jpg differ diff --git a/example/assets/flashlight/camera.png b/example/assets/flashlight/camera.png new file mode 100644 index 0000000..7d0e7cd Binary files /dev/null and b/example/assets/flashlight/camera.png differ diff --git a/example/assets/flashlight/corridor.jpg b/example/assets/flashlight/corridor.jpg new file mode 100644 index 0000000..cdd951b Binary files /dev/null and b/example/assets/flashlight/corridor.jpg differ diff --git a/example/assets/flashlight/flashlight-off.png b/example/assets/flashlight/flashlight-off.png new file mode 100644 index 0000000..f274b46 Binary files /dev/null and b/example/assets/flashlight/flashlight-off.png differ diff --git a/example/assets/flashlight/torch-glyph.png b/example/assets/flashlight/torch-glyph.png new file mode 100644 index 0000000..c061934 Binary files /dev/null and b/example/assets/flashlight/torch-glyph.png differ diff --git a/example/assets/icon.png b/example/assets/icon.png index ecbefa4..afa00c5 100644 Binary files a/example/assets/icon.png and b/example/assets/icon.png differ diff --git a/example/assets/lavalamp/lava-lamp.jpg b/example/assets/lavalamp/lava-lamp.jpg new file mode 100644 index 0000000..8878e2d Binary files /dev/null and b/example/assets/lavalamp/lava-lamp.jpg differ diff --git a/example/assets/namedrop/avatar.jpg b/example/assets/namedrop/avatar.jpg new file mode 100644 index 0000000..6e9d919 Binary files /dev/null and b/example/assets/namedrop/avatar.jpg differ diff --git a/example/assets/namedrop/home-atlas.jpg b/example/assets/namedrop/home-atlas.jpg new file mode 100644 index 0000000..a887b8f Binary files /dev/null and b/example/assets/namedrop/home-atlas.jpg differ diff --git a/example/assets/polaroid/photo-beach.jpg b/example/assets/polaroid/photo-beach.jpg new file mode 100644 index 0000000..880f4c2 Binary files /dev/null and b/example/assets/polaroid/photo-beach.jpg differ diff --git a/example/assets/polaroid/photo-lake.jpg b/example/assets/polaroid/photo-lake.jpg new file mode 100644 index 0000000..438fb8d Binary files /dev/null and b/example/assets/polaroid/photo-lake.jpg differ diff --git a/example/assets/polaroid/photo-mountains.jpg b/example/assets/polaroid/photo-mountains.jpg new file mode 100644 index 0000000..8eba133 Binary files /dev/null and b/example/assets/polaroid/photo-mountains.jpg differ diff --git a/example/assets/polaroid/table.jpg b/example/assets/polaroid/table.jpg new file mode 100644 index 0000000..aa488f7 Binary files /dev/null and b/example/assets/polaroid/table.jpg differ diff --git a/example/assets/splash-icon.png b/example/assets/splash-icon.png index ecbefa4..3f93869 100644 Binary files a/example/assets/splash-icon.png and b/example/assets/splash-icon.png differ diff --git a/example/metro-get-modules-polyfill.js b/example/metro-get-modules-polyfill.js new file mode 100644 index 0000000..2056070 --- /dev/null +++ b/example/metro-get-modules-polyfill.js @@ -0,0 +1,6 @@ +// Worklets Bundle Mode calls require.getModules(), which Expo's module system +// only defines in dev builds. Must run before the first __d registers a module. +if (!global.__r.getModules) { + const modules = global.__c(); + global.__r.getModules = () => modules; +} diff --git a/example/metro.config.js b/example/metro.config.js index 4d5a538..50d746d 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -21,6 +21,12 @@ config.resolver = { config = getBundleModeMetroConfig(config); +const getPolyfills = config.serializer.getPolyfills; +config.serializer.getPolyfills = (options) => [ + ...getPolyfills(options), + path.resolve(__dirname, 'metro-get-modules-polyfill.js'), +]; + // CRITICAL: Enable inlineRequires for worklets compatibility in Expo config.transformer = { ...config.transformer, diff --git a/example/package.json b/example/package.json index 4e58489..4ba7820 100644 --- a/example/package.json +++ b/example/package.json @@ -13,11 +13,16 @@ "dependencies": { "@react-navigation/native": "7.2.5", "@react-navigation/native-stack": "7.16.0", - "expo": "~58.0.16", + "expo": "~58.0.21", + "expo-blur": "~58.0.5", "expo-build-properties": "~58.0.11", + "expo-camera": "~58.0.0", + "expo-glass-effect": "~58.0.5", "expo-sensors": "~58.0.4", + "expo-splash-screen": "~58.0.12", + "expo-symbols": "~58.0.4", "react": "19.3.0", - "react-native": "0.88.0", + "react-native": "0.88.1", "react-native-audio-api": "^0.13.6", "react-native-effects": "workspace:*", "react-native-gesture-handler": "~3.3.0", @@ -32,7 +37,7 @@ "@babel/core": "^7.29.7", "@babel/preset-env": "^7.29.7", "@babel/runtime": "^7.29.7", - "@react-native/metro-config": "^0.88.0", + "@react-native/metro-config": "^0.88.1", "@types/react": "~19.3.0", "@webgpu/types": "0.1.70", "babel-preset-expo": "~58.0.0", diff --git a/example/src/App.tsx b/example/src/App.tsx index d48d492..feee9be 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -6,8 +6,11 @@ import { } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { Platform, StyleSheet } from 'react-native'; +import type { NativeStackNavigationOptions } from '@react-navigation/native-stack'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import HomeScreen from './screens/HomeScreen'; +import SearchScreen from './screens/SearchScreen'; +import AboutScreen from './screens/AboutScreen'; import LinearGradientListScreen from './screens/LinearGradient/LinearGradientListScreen'; import LinearGradientAnimatedScreen from './screens/LinearGradient/LinearGradientAnimatedScreen'; import LinearGradientStaticScreen from './screens/LinearGradient/LinearGradientStaticScreen'; @@ -39,18 +42,27 @@ import ParticleDispersionScreen from './screens/ParticleDispersionScreen'; import FoamLabScreen from './screens/FoamLabScreen'; import VoiceDeleteScreen from './screens/VoiceDeleteScreen'; import Magic8BallScreen from './screens/Magic8BallScreen'; -import CigaretteScreen from './screens/CigaretteScreen'; import DandelionScreen from './screens/DandelionScreen'; import HourglassScreen from './screens/HourglassScreen'; import TelegramSpoilerScreen from './screens/TelegramSpoilerScreen'; import IMessageScreen from './screens/IMessageScreen'; +import CrackedScreenScreen from './screens/CrackedScreenScreen'; +import DuoCrackgateScreen from './screens/DuoCrackgateScreen'; +import PolaroidScreen from './screens/PolaroidScreen'; +import ScratchOffScreen from './screens/ScratchOffScreen'; +import BubbleWrapScreen from './screens/BubbleWrapScreen'; +import LavaLampScreen from './screens/LavaLampScreen'; +import FerrofluidScreen from './screens/FerrofluidScreen'; +import CrtOffScreen from './screens/CrtOffScreen'; +import FlashlightScreen from './screens/FlashlightScreen'; +import WalletPayScreen from './screens/WalletPayScreen'; +import NameDropScreen from './screens/NameDropScreen'; import type { RootStackParamList } from './types'; const Stack = createNativeStackNavigator(); -// Dark theme so the surface revealed behind/around the cards during a stack -// transition is black, not React Navigation's default light grey (which flashed -// white at the screen edges while sliding). +// Dark theme so the surface revealed during a stack transition stays black, +// not React Navigation's default light grey. const navTheme: Theme = { ...DefaultTheme, dark: true, @@ -61,6 +73,16 @@ const navTheme: Theme = { }, }; +const listHeader = (title: string): NativeStackNavigationOptions => ({ + title, + headerShown: true, + headerTintColor: '#fff', + headerShadowVisible: false, + headerBackButtonDisplayMode: 'minimal', + headerStyle: { backgroundColor: '#000' }, + headerTitleStyle: { color: '#fff' }, +}); + export default function App() { return ( @@ -68,15 +90,29 @@ export default function App() { + + @@ -141,7 +179,6 @@ export default function App() { component={VoiceDeleteScreen} /> - + + + + + + + + + + + diff --git a/example/src/catalog/effects.ts b/example/src/catalog/effects.ts new file mode 100644 index 0000000..d6caf88 --- /dev/null +++ b/example/src/catalog/effects.ts @@ -0,0 +1,430 @@ +import type { ImageSourcePropType } from 'react-native'; +import type { RootStackParamList } from '../types'; + +export type Effect = { + id: string; + title: string; + line: string; + screen: keyof RootStackParamList; + image: ImageSourcePropType; + hero?: ImageSourcePropType; +}; + +export type SectionLayout = + | 'posters' + | 'diptych' + | 'lead' + | 'tiles' + | 'wide' + | 'orbs' + | 'rows' + | 'wall'; + +export type CatalogSection = { + id: string; + title: string; + layout: SectionLayout; + effects: Effect[]; +}; + +export const SECTIONS: CatalogSection[] = [ + { + id: 'tilt', + title: 'Tilt it.', + layout: 'posters', + effects: [ + { + id: 'beer', + title: 'Beer', + line: 'Tilt to slosh, rotate to drink, tap to refill', + screen: 'BeerExample', + image: require('../../assets/catalog/beer.jpg'), + }, + { + id: 'lighter', + title: 'Lighter', + line: 'Tap to strike. The flame stays upright as you tilt', + screen: 'LighterExample', + image: require('../../assets/catalog/lighter.jpg'), + hero: require('../../assets/catalog/lighter-hero.jpg'), + }, + { + id: 'hourglass', + title: 'Hourglass', + line: 'Run a Pomodoro, then flip your phone to restart it', + screen: 'HourglassExample', + image: require('../../assets/catalog/hourglass.jpg'), + }, + { + id: 'lava-lamp', + title: 'Lava Lamp', + line: 'Warm wax rises. Tilt to stir it', + screen: 'LavaLampExample', + image: require('../../assets/catalog/lava-lamp.jpg'), + hero: require('../../assets/catalog/lava-lamp-hero.jpg'), + }, + { + id: 'holo-foil', + title: 'Holo Foil', + line: 'Rainbow foil that shifts as you tilt', + screen: 'HoloFoilExample', + image: require('../../assets/catalog/holo-foil.jpg'), + }, + { + id: 'holo-card', + title: 'Cards', + line: 'Iridescent bank cards that catch the light', + screen: 'HoloCardExample', + image: require('../../assets/catalog/holo-card.jpg'), + }, + ], + }, + { + id: 'blow', + title: 'Blow on it.', + layout: 'diptych', + effects: [ + { + id: 'birthday-candle', + title: 'Birthday Candle', + line: 'Blow on your phone to put it out, tap to relight', + screen: 'BirthdayCandleExample', + image: require('../../assets/catalog/birthday-candle.jpg'), + hero: require('../../assets/catalog/birthday-candle-hero.jpg'), + }, + { + id: 'dandelion', + title: 'Dandelion', + line: 'Blow on your phone and the seeds drift away', + screen: 'DandelionExample', + image: require('../../assets/catalog/dandelion.jpg'), + }, + ], + }, + { + id: 'break', + title: 'Break it.', + layout: 'lead', + effects: [ + { + id: 'cracked-screen', + title: 'Cracked Screen', + line: 'Tap hard and the glass breaks where you hit it', + screen: 'CrackedScreenExample', + image: require('../../assets/catalog/cracked-screen.jpg'), + hero: require('../../assets/catalog/cracked-screen-hero.jpg'), + }, + { + id: 'duo-crackgate', + title: 'Crackgate', + line: 'The iPhone Duo hinge crack, white pixels and all', + screen: 'DuoCrackgateExample', + image: require('../../assets/catalog/duo-crackgate.jpg'), + }, + { + id: 'crt-off', + title: 'CRT Off', + line: 'Close the app like an old TV', + screen: 'CrtOffExample', + image: require('../../assets/catalog/crt-off.jpg'), + }, + { + id: 'thanos', + title: 'Thanos', + line: 'Tap to snap a card into dust', + screen: 'ThanosExample', + image: require('../../assets/catalog/thanos.jpg'), + }, + { + id: 'particle-dispersion', + title: 'Particle Dispersion', + line: 'Lift the subject and the background blows away as dust', + screen: 'ParticleDispersionExample', + image: require('../../assets/catalog/particle-dispersion.jpg'), + }, + ], + }, + { + id: 'touch', + title: 'Touch it.', + layout: 'tiles', + effects: [ + { + id: 'scratch-off', + title: 'Scratch-off', + line: 'Scratch the foil to reveal your prize', + screen: 'ScratchOffExample', + image: require('../../assets/catalog/scratch-off.jpg'), + }, + { + id: 'bubble-wrap', + title: 'Bubble Wrap', + line: 'Pop every last bubble', + screen: 'BubbleWrapExample', + image: require('../../assets/catalog/bubble-wrap.jpg'), + }, + { + id: 'flashlight', + title: 'Flashlight', + line: 'The Dynamic Island torch. Swipe to shape the beam', + screen: 'FlashlightExample', + image: require('../../assets/catalog/flashlight.jpg'), + }, + { + id: 'ferrofluid', + title: 'Ferrofluid', + line: 'Hold a real magnet near your phone', + screen: 'FerrofluidExample', + image: require('../../assets/catalog/ferrofluid.jpg'), + }, + { + id: 'wallet', + title: 'Wallet', + line: 'Double-click to pay, the card lifts and glints', + screen: 'WalletPayExample', + image: require('../../assets/catalog/wallet.jpg'), + }, + { + id: 'namedrop', + title: 'NameDrop', + line: 'Bring two iPhones together and swap contacts', + screen: 'NameDropExample', + image: require('../../assets/catalog/namedrop.jpg'), + }, + { + id: 'touch-field', + title: 'Sauron Eye', + line: 'The lidless Eye, wreathed in flame', + screen: 'TouchFieldExample', + image: require('../../assets/catalog/touch-field.jpg'), + }, + { + id: 'scroll-reactive', + title: 'Scroll Reactive', + line: 'A background that reacts to your scroll', + screen: 'ScrollReactiveExample', + image: require('../../assets/catalog/scroll-reactive.jpg'), + }, + ], + }, + { + id: 'shake', + title: 'Shake it.', + layout: 'wide', + effects: [ + { + id: 'magic-8-ball', + title: 'Magic 8-ball', + line: 'Shake your phone and the answer floats up through the ink', + screen: 'Magic8BallExample', + image: require('../../assets/catalog/magic-8-ball.jpg'), + }, + { + id: 'polaroid', + title: 'Polaroid', + line: 'Snap a photo and shake it while it develops', + screen: 'PolaroidExample', + image: require('../../assets/catalog/polaroid.jpg'), + }, + ], + }, + { + id: 'talk', + title: 'Talk to it.', + layout: 'orbs', + effects: [ + { + id: 'ai-orb', + title: 'AI Orb', + line: 'A morphing orb that listens while it thinks', + screen: 'AiOrbExample', + image: require('../../assets/catalog/ai-orb.jpg'), + }, + { + id: 'siri-orb', + title: 'Siri Orb', + line: 'Luminous breathing sphere with swirling colors', + screen: 'SiriOrbStatic', + image: require('../../assets/catalog/siri-orb.jpg'), + }, + { + id: 'ai-voice', + title: 'AI Voice', + line: 'A waveform that follows your voice', + screen: 'AiVoiceExample', + image: require('../../assets/catalog/ai-voice.jpg'), + }, + { + id: 'ai-shimmer', + title: 'AI Shimmer', + line: 'The shimmer that plays while an answer arrives', + screen: 'AiShimmerExample', + image: require('../../assets/catalog/ai-shimmer.jpg'), + }, + ], + }, + { + id: 'send', + title: 'Send it.', + layout: 'rows', + effects: [ + { + id: 'imessage', + title: 'iMessage Effects', + line: 'Hold send to pick lasers, confetti, fireworks and more', + screen: 'IMessageExample', + image: require('../../assets/catalog/imessage.jpg'), + }, + { + id: 'telegram-spoiler', + title: 'Telegram Spoiler', + line: 'Tap the hidden text to reveal it for a moment', + screen: 'TelegramSpoilerExample', + image: require('../../assets/catalog/telegram-spoiler.jpg'), + }, + { + id: 'telegram-dissolve', + title: 'Telegram Dissolve', + line: 'Delete a message and watch it turn to dust', + screen: 'TelegramDissolveExample', + image: require('../../assets/catalog/telegram-dissolve.jpg'), + }, + { + id: 'voice-delete', + title: 'Voice Delete', + line: 'Delete a voice message in a puff of smoke', + screen: 'VoiceDeleteExample', + image: require('../../assets/catalog/voice-delete.jpg'), + }, + { + id: 'banners', + title: 'Banners', + line: 'iOS notification banners with shader effects', + screen: 'BannersExample', + image: require('../../assets/catalog/banners.jpg'), + }, + ], + }, + { + id: 'watch', + title: 'Just watch.', + layout: 'wall', + effects: [ + { + id: 'aurora', + title: 'Aurora', + line: 'Northern lights with flowing curtains of light', + screen: 'AuroraStatic', + image: require('../../assets/catalog/aurora.jpg'), + hero: require('../../assets/catalog/aurora-hero.jpg'), + }, + { + id: 'liquid-chrome', + title: 'Liquid Chrome', + line: 'Fluid metallic surfaces', + screen: 'LiquidChromeStatic', + image: require('../../assets/catalog/liquid-chrome.jpg'), + }, + { + id: 'campfire', + title: 'Campfire', + line: 'Fire with drifting sparks and smoke', + screen: 'CampfireStatic', + image: require('../../assets/catalog/campfire.jpg'), + }, + { + id: 'iridescence', + title: 'Iridescence', + line: 'Soap-film color in slow motion', + screen: 'IridescenceStatic', + image: require('../../assets/catalog/iridescence.jpg'), + }, + { + id: 'silk', + title: 'Silk', + line: 'Smooth flowing fabric', + screen: 'SilkStatic', + image: require('../../assets/catalog/silk.jpg'), + }, + { + id: 'ink-bloom', + title: 'Ink Bloom', + line: 'A drop of ink curling through water', + screen: 'InkBloomExample', + image: require('../../assets/catalog/ink-bloom.jpg'), + }, + { + id: 'weather', + title: 'Weather', + line: 'A full forecast app with living skies', + screen: 'WeatherExample', + image: require('../../assets/catalog/weather.jpg'), + }, + { + id: 'calico-swirl', + title: 'Calico Swirl', + line: 'Warped noise with flowing colors', + screen: 'CalicoSwirlStatic', + image: require('../../assets/catalog/calico-swirl.jpg'), + }, + { + id: 'materials', + title: 'Materials', + line: 'Natural material banners. Tap to go full screen', + screen: 'MaterialsExample', + image: require('../../assets/catalog/materials.jpg'), + }, + { + id: 'foamlab', + title: 'Beer Foam', + line: 'Three foam treatments side by side', + screen: 'FoamLabExample', + image: require('../../assets/catalog/foamlab.jpg'), + }, + { + id: 'circular-gradient', + title: 'Circular Gradient', + line: 'Customizable radial gradients', + screen: 'CircularGradientList', + image: require('../../assets/catalog/circular-gradient.jpg'), + }, + { + id: 'linear-gradient', + title: 'Linear Gradient', + line: 'Smooth linear gradients, still or animated', + screen: 'LinearGradientList', + image: require('../../assets/catalog/linear-gradient.jpg'), + }, + ], + }, +]; + +const ALL_EFFECTS = SECTIONS.flatMap((section) => section.effects); + +const FEATURED_IDS = [ + 'lava-lamp', + 'cracked-screen', + 'lighter', + 'birthday-candle', + 'aurora', +]; + +export const FEATURED_EFFECTS = ALL_EFFECTS.filter((effect) => + FEATURED_IDS.includes(effect.id) +).sort((a, b) => FEATURED_IDS.indexOf(a.id) - FEATURED_IDS.indexOf(b.id)); + +const ALPHABETICAL_ENTRIES = SECTIONS.flatMap((section) => + section.effects.map((effect) => ({ + effect, + haystack: [section.title, effect.title, effect.line] + .join(' ') + .toLowerCase(), + })) +).sort((a, b) => a.effect.title.localeCompare(b.effect.title)); + +export function searchEffects(query: string) { + const needle = query.trim().toLowerCase(); + return ALPHABETICAL_ENTRIES.filter((entry) => + entry.haystack.includes(needle) + ).map((entry) => entry.effect); +} diff --git a/example/src/catalog/theme.ts b/example/src/catalog/theme.ts new file mode 100644 index 0000000..46bd2df --- /dev/null +++ b/example/src/catalog/theme.ts @@ -0,0 +1,53 @@ +import { StyleSheet } from 'react-native'; + +export const colors = { + background: '#000', + sheet: '#121212', + surface: '#161616', + text: '#fff', + secondary: 'rgba(235, 235, 245, 0.62)', + tertiary: 'rgba(235, 235, 245, 0.32)', + separator: 'rgba(235, 235, 245, 0.16)', +}; + +export const SERIF = 'ui-serif'; + +export const SCREEN_MARGIN = 20; + +export const scrims = StyleSheet.create({ + bottom: { + experimental_backgroundImage: + 'linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.78) 100%)', + }, + top: { + experimental_backgroundImage: + 'linear-gradient(to bottom, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 100%)', + }, +}); + +export const type = StyleSheet.create({ + overlayTitle: { + fontFamily: SERIF, + fontSize: 22, + fontWeight: '600', + color: colors.text, + }, + overlayLine: { + marginTop: 2, + fontSize: 14, + lineHeight: 19, + color: 'rgba(255, 255, 255, 0.82)', + }, + captionTitle: { + fontSize: 16, + fontWeight: '600', + letterSpacing: -0.2, + color: colors.text, + }, + captionLine: { + marginTop: 2, + fontSize: 14, + lineHeight: 19, + color: colors.secondary, + }, +}); diff --git a/example/src/components/BackButton.tsx b/example/src/components/BackButton.tsx deleted file mode 100644 index 62885a6..0000000 --- a/example/src/components/BackButton.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { TouchableOpacity, Text, StyleSheet } from 'react-native'; -import type { ViewStyle } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; - -type BackButtonProps = { - style?: ViewStyle; -}; - -export function BackButton({ style }: BackButtonProps) { - const navigation = useNavigation(); - - return ( - navigation.goBack()} - > - ← Back - - ); -} - -const styles = StyleSheet.create({ - backButton: { - alignSelf: 'flex-start', - backgroundColor: 'rgba(255, 255, 255, 0.1)', - paddingHorizontal: 18, - paddingVertical: 10, - borderRadius: 20, - marginBottom: 20, - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.25)', - }, - backButtonText: { - color: '#fff', - fontSize: 15, - fontWeight: '600', - }, -}); diff --git a/example/src/components/Header.tsx b/example/src/components/Header.tsx deleted file mode 100644 index 394b3b4..0000000 --- a/example/src/components/Header.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { View, Text, StyleSheet, type ViewStyle } from 'react-native'; -import { BackButton } from './BackButton'; - -type HeaderProps = { - title: string; - subtitle?: string; - transparent?: boolean; - style?: ViewStyle; - backButtonStyle?: ViewStyle; -}; - -export function Header({ - title, - subtitle, - transparent = false, - style, - backButtonStyle, -}: HeaderProps) { - return ( - - - {title} - {subtitle && {subtitle}} - - ); -} - -const styles = StyleSheet.create({ - header: { - paddingTop: 60, - paddingHorizontal: 28, - paddingBottom: 28, - }, - solidHeader: { - backgroundColor: '#111', - borderBottomWidth: 1, - borderBottomColor: '#222', - }, - transparentHeader: { - backgroundColor: 'transparent', - position: 'absolute', - top: 0, - left: 0, - right: 0, - zIndex: 100, - }, - title: { - fontSize: 32, - fontWeight: '800', - color: '#fff', - marginBottom: 10, - letterSpacing: -0.5, - }, - subtitle: { - fontSize: 16, - color: '#999', - fontWeight: '400', - }, -}); diff --git a/example/src/components/bubblewrap/BubbleWrap.tsx b/example/src/components/bubblewrap/BubbleWrap.tsx new file mode 100644 index 0000000..6444835 --- /dev/null +++ b/example/src/components/bubblewrap/BubbleWrap.tsx @@ -0,0 +1,223 @@ +import { useMemo } from 'react'; +import type { ImageSourcePropType, ViewProps } from 'react-native'; +import { ShaderView, type ParamsSynchronizable } from 'react-native-effects'; +import type { BubbleGrid } from '../../hooks/useBubbleWrap'; + +type Props = ViewProps & { + /** What the sheet lies on; seen through the film and bent by each dome. */ + surface: ImageSourcePropType; + /** `[scaleX, scaleY]` mapping screen uv onto the photo, cover-fit. */ + coverScale: [number, number]; + grid: BubbleGrid; + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A sheet of clear bubble wrap lying on a photographed surface. + * + * Every bubble is a shallow dome of clear film: it magnifies and bends the + * surface under it, throws a soft shadow and a little focused light onto it, + * and carries a soft window highlight and a bright Fresnel rim. The flat + * film between bubbles is faintly crinkled. A popped bubble (its float in the + * live channel goes 0 to 1) slumps into a flat, creased, slightly whitened + * disc whose highlight breaks into crumbs. + */ +export default function BubbleWrap({ + surface, + coverScale, + grid, + paramsSynchronizable, + ...viewProps +}: Props) { + const params = useMemo( + () => [ + grid.spacing, + grid.rowHeight, + grid.stride, + grid.rows, + coverScale[0], + coverScale[1], + ], + [grid, coverScale] + ); + return ( + + ); +} + +const BUBBLE_SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, + liveData: array, 96>, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +const RADIUS = 0.44; + +fn hash21(p: vec2) -> f32 { + var p3 = fract(vec3(p.xyx) * 0.1031); + p3 = p3 + dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +fn vnoise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let w = f * f * (3.0 - 2.0 * f); + let a = hash21(i); + let b = hash21(i + vec2(1.0, 0.0)); + let c = hash21(i + vec2(0.0, 1.0)); + let d = hash21(i + vec2(1.0, 1.0)); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); +} + +// Screen points (y down) -> the photo. +fn surfaceAt(pt: vec2) -> vec3 { + let size = u.resolution.xy / u.resolution.w; + let suv = pt / size; + let iuv = (suv - 0.5) * u.params1.xy + 0.5; + return textureSampleLevel(tex, samp, iuv, 0.0).rgb; +} + +fn bubbleState(idx: i32) -> f32 { + let i = clamp(idx, 0, 383); + let v = u.liveData[i / 4]; + return v[i % 4]; +} + +struct Nearest { + center: vec2, + idx: i32, + valid: f32, +}; + +fn nearestBubble(pt: vec2) -> Nearest { + let sp = u.params0.x; + let rowH = u.params0.y; + let stride = i32(u.params0.z); + let rows = i32(u.params0.w); + let r0 = i32(floor(pt.y / rowH - 0.5)); + var best: Nearest; + best.valid = 0.0; + best.idx = 0; + best.center = vec2(-1000.0); + var bestD = 1e9; + for (var k = 0; k < 2; k = k + 1) { + let row = r0 + k; + let shift = select(0.0, 0.5, (row & 1) == 0); + let col = i32(round(pt.x / sp - shift)); + let center = vec2((f32(col) + shift) * sp, (f32(row) + 0.5) * rowH); + let d = distance(pt, center); + let ok = row >= 0 && row < rows && col >= 0 && col < stride; + if (ok && d < bestD) { + bestD = d; + best.center = center; + best.idx = row * stride + col; + best.valid = 1.0; + } + } + return best; +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let size = u.resolution.xy / u.resolution.w; + let pt = vec2(uv.x, 1.0 - uv.y) * size; + let sp = u.params0.x; + let R = sp * RADIUS; + let aa = 1.2 / (R * u.resolution.w); + + let nb = nearestBubble(pt); + let popRaw = select(0.0, bubbleState(nb.idx), nb.valid > 0.5); + let pop = popRaw * popRaw * (3.0 - 2.0 * popRaw); + let seed = hash21(vec2(f32(nb.idx), 7.0)); + // Local bubble coords, y up, 1.0 at the dome's foot. + let d = pt - nb.center; + let o = vec2(d.x, -d.y) / R; + let rr = length(o); + let inside = smoothstep(1.0 + aa, 1.0 - aa, rr) * nb.valid; + + // Dome: a squashed hemisphere that slumps to almost nothing when popped. + let domeH = mix(0.72 + 0.16 * seed, 0.07, pop); + let foot = sqrt(max(1.0 - rr * rr, 0.035)); + var n = vec3(o * domeH / foot, 1.0); + // Crumple: creases radiate from the middle; a popped film is a mess of + // small facets. + let ang = atan2(o.y, o.x); + let creaseA = vnoise(vec2(ang * 2.6 + seed * 40.0, rr * 2.0)); + let creaseB = vnoise(o * 4.2 + seed * 13.0); + let creaseC = vnoise(o * 4.2 + seed * 13.0 + 31.7); + let crumple = vec2(creaseB - 0.5, creaseC - 0.5) * 2.2 + vec2(cos(ang), sin(ang)) * (creaseA - 0.5) * 2.4; + n = vec3(n.xy + crumple * pop * smoothstep(1.02, 0.55, rr), n.z); + n = normalize(n); + + // Film between bubbles: barely crinkled. + let fw = vec2(vnoise(pt * 0.07), vnoise(pt * 0.07 + 9.1)) - 0.5; + let fw2 = vec2(vnoise(pt * 0.23 + 3.3), vnoise(pt * 0.23 + 5.9)) - 0.5; + let nFilm = normalize(vec3(fw * 0.22 + fw2 * 0.08, 1.0)); + let nrm = normalize(mix(nFilm, n, inside)); + + // The surface under the film: magnified and bent by each intact dome, + // shadowed beside it, brightened where the dome focuses light. + let lens = (1.0 - pop) * inside; + let refr = nb.center + d * mix(1.0, 0.84, lens) + vec2(nrm.x, -nrm.y) * 5.0 * (lens + pop * inside * 0.4); + var base = surfaceAt(refr); + let sh = distance(pt, nb.center + vec2(R * 0.22, R * 0.34)) / R; + let shadow = 0.24 * (1.0 - 0.8 * pop) * smoothstep(1.08, 0.72, sh) * (1.0 - lens * 0.7) * nb.valid; + let focus = 0.18 * lens * smoothstep(0.75, 0.0, distance(o, vec2(0.15, -0.2))); + base = base * (1.0 - shadow) * (1.0 + focus); + base = base * 0.92 + vec3(0.012, 0.014, 0.018); + + // Light: a window up and to the left, a soft room fill. + // A camera and a window at finite distances, so the reflection slides and + // stretches across the sheet instead of repeating on every bubble. + let fromEye = vec2(pt.x - size.x * 0.5, size.y * 0.5 - pt.y); + let v = normalize(vec3(-fromEye, 1400.0)); + let r = reflect(-v, nrm); + let onCeiling = r.xy / max(r.z, 0.1) * 420.0 + vec2(pt.x, -pt.y); + let windowCenter = vec2(-0.7 * size.x, 0.45 * size.y); + let wq = (onCeiling - windowCenter) / vec2(0.8 * size.x, 0.7 * size.y); + let streak = smoothstep(1.0, 0.6, length(wq)) * (0.7 + 0.3 * smoothstep(-0.4, 0.6, wq.y)); + let toLamp = vec3(-0.4 * size.x - pt.x, pt.y + 0.5 * size.y, 900.0); + let sunH = normalize(normalize(toLamp) + v); + let sun = pow(clamp(dot(nrm, sunH), 0.0, 1.0), 320.0); + let k = 1.0 - clamp(nrm.z, 0.0, 1.0); + let fres = 0.04 + 0.96 * k * k * k; + + var col = base; + col = col + vec3(1.0, 0.99, 0.97) * streak * (0.5 + fres) * mix(1.0, 0.45, pop) * (0.2 + 0.8 * inside); + col = col + vec3(1.0) * min(sun * 1.1, 1.0); + let lit = dot(o / max(rr, 1e-3), vec2(-0.6, 0.8)); + let crescent = smoothstep(0.62, 0.84, rr) * smoothstep(0.98, 0.88, rr) * smoothstep(0.1, 0.85, lit); + col = col + vec3(1.0, 0.98, 0.95) * 0.42 * crescent * inside * (1.0 - pop); + col = col + vec3(0.85, 0.9, 1.0) * fres * 0.55 * inside; + // Total internal reflection darkens the dome's steep foot; the welded + // seam around it shows as a thin bright line. + col = col * (1.0 - 0.22 * smoothstep(0.72, 0.97, rr) * inside * (1.0 - pop)); + let away = dot(o, vec2(0.6, -0.8)); + col = col * (1.0 - 0.16 * smoothstep(0.2, 0.95, away) * inside * (1.0 - pop)); + let bounce = smoothstep(0.55, 0.85, away) * smoothstep(1.0, 0.9, rr); + col = col + vec3(0.9, 0.85, 0.75) * 0.1 * bounce * inside * (1.0 - pop); + let seam = smoothstep(0.06, 0.0, abs(rr - 1.03)) * nb.valid; + col = col + vec3(0.10) * seam; + // A popped film is stretched and whitened a little. + col = mix(col, vec3(0.86, 0.88, 0.9), 0.16 * pop * inside * (0.4 + creaseB)); + + col = col + (hash21(uv * u.resolution.xy) - 0.5) / 255.0; + return vec4(col, 1.0); +}`; diff --git a/example/src/components/catalog/EffectRow.tsx b/example/src/components/catalog/EffectRow.tsx new file mode 100644 index 0000000..2e5c6ee --- /dev/null +++ b/example/src/components/catalog/EffectRow.tsx @@ -0,0 +1,70 @@ +import { memo } from 'react'; +import { Image, StyleSheet, Text, View } from 'react-native'; +import { colors, SCREEN_MARGIN, type } from '../../catalog/theme'; +import type { Effect } from '../../catalog/effects'; +import { Chevron } from './Glyphs'; +import { PressableScale } from './PressableScale'; + +type EffectRowProps = { + effect: Effect; + onOpen: (effect: Effect) => void; + showSeparator?: boolean; +}; + +export const EffectRow = memo(function EffectRow({ + effect, + onOpen, + showSeparator = false, +}: EffectRowProps) { + return ( + onOpen(effect)} + accessibilityLabel={effect.title} + accessibilityHint={effect.line} + style={styles.row} + > + + + + + {effect.title} + + + {effect.line} + + + + + + ); +}); + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + paddingLeft: SCREEN_MARGIN, + }, + thumbnail: { + width: 58, + height: 84, + marginVertical: 8, + borderRadius: 10, + backgroundColor: colors.surface, + }, + body: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + marginLeft: 14, + paddingRight: SCREEN_MARGIN, + paddingVertical: 8, + }, + separator: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.separator, + }, + text: { + flex: 1, + paddingRight: 12, + }, +}); diff --git a/example/src/components/catalog/Glyphs.tsx b/example/src/components/catalog/Glyphs.tsx new file mode 100644 index 0000000..94f9c69 --- /dev/null +++ b/example/src/components/catalog/Glyphs.tsx @@ -0,0 +1,81 @@ +import { StyleSheet, View } from 'react-native'; +import { colors } from '../../catalog/theme'; + +export function SearchGlyph() { + return ( + + + + + ); +} + +export function MicGlyph() { + return ( + + + + + + ); +} + +export function Chevron() { + return ; +} + +const styles = StyleSheet.create({ + searchBox: { + width: 18, + height: 18, + }, + searchRing: { + width: 13, + height: 13, + borderRadius: 6.5, + borderWidth: 2, + borderColor: colors.text, + }, + searchHandle: { + position: 'absolute', + right: 0, + bottom: 2.5, + width: 7, + height: 2, + borderRadius: 1, + backgroundColor: colors.text, + transform: [{ rotate: '45deg' }], + }, + micBox: { + alignItems: 'center', + }, + micHead: { + width: 10, + height: 17, + borderRadius: 5, + backgroundColor: colors.text, + }, + micCradle: { + width: 18, + height: 10, + marginTop: -5, + borderBottomLeftRadius: 9, + borderBottomRightRadius: 9, + borderWidth: 2, + borderTopWidth: 0, + borderColor: colors.text, + }, + micStand: { + width: 2, + height: 5, + backgroundColor: colors.text, + }, + chevron: { + width: 8, + height: 8, + borderTopWidth: 1.75, + borderRightWidth: 1.75, + borderColor: colors.tertiary, + transform: [{ rotate: '45deg' }], + }, +}); diff --git a/example/src/components/catalog/Hero.tsx b/example/src/components/catalog/Hero.tsx new file mode 100644 index 0000000..32bb966 --- /dev/null +++ b/example/src/components/catalog/Hero.tsx @@ -0,0 +1,228 @@ +import { memo, useCallback } from 'react'; +import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'; +import Animated, { + Extrapolation, + interpolate, + useAnimatedScrollHandler, + useAnimatedStyle, + useSharedValue, + type SharedValue, +} from 'react-native-reanimated'; +import { colors, SCREEN_MARGIN, scrims, SERIF } from '../../catalog/theme'; +import type { Effect } from '../../catalog/effects'; +import { PressableScale } from './PressableScale'; + +const HERO_ASPECT = 0.68; +const MAX_HEIGHT_RATIO = 0.74; +const PARALLAX = 56; + +export function useHeroHeight() { + const { width, height } = useWindowDimensions(); + return Math.min(width / HERO_ASPECT, height * MAX_HEIGHT_RATIO); +} + +type HeroProps = { + effects: Effect[]; + scrollY: SharedValue; + onOpen: (effect: Effect) => void; +}; + +type HeroPageProps = { + effect: Effect; + index: number; + width: number; + height: number; + pagerX: SharedValue; + onOpen: (effect: Effect) => void; +}; + +function HeroPage({ + effect, + index, + width, + height, + pagerX, + onOpen, +}: HeroPageProps) { + const imageStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateX: interpolate( + pagerX.value, + [(index - 1) * width, index * width, (index + 1) * width], + [-PARALLAX, 0, PARALLAX] + ), + }, + ], + })); + + return ( + onOpen(effect)} + accessibilityLabel={`${effect.title}. ${effect.line}`} + accessibilityHint="Opens this effect" + style={{ width, height }} + > + + + + + {effect.title} + + + {effect.line} + + + + + ); +} + +function PageMark({ + index, + width, + pagerX, +}: { + index: number; + width: number; + pagerX: SharedValue; +}) { + const style = useAnimatedStyle(() => ({ + opacity: interpolate( + pagerX.value, + [(index - 1) * width, index * width, (index + 1) * width], + [0.3, 1, 0.3], + Extrapolation.CLAMP + ), + })); + + return ; +} + +export const Hero = memo(function Hero({ + effects, + scrollY, + onOpen, +}: HeroProps) { + const { width } = useWindowDimensions(); + const height = useHeroHeight(); + const pagerX = useSharedValue(0); + + const onPagerScroll = useAnimatedScrollHandler((event) => { + pagerX.value = event.contentOffset.x; + }); + + const stretchStyle = useAnimatedStyle(() => { + const overscroll = Math.max(-scrollY.value, 0); + return { + transform: [ + { translateY: -overscroll / 2 }, + { scale: 1 + overscroll / height }, + ], + }; + }); + + const renderPage = useCallback( + ({ item, index }: { item: Effect; index: number }) => ( + + ), + [width, height, pagerX, onOpen] + ); + + return ( + + effect.id} + renderItem={renderPage} + showsHorizontalScrollIndicator={false} + onScroll={onPagerScroll} + scrollEventThrottle={16} + getItemLayout={(_, index) => ({ + length: width, + offset: width * index, + index, + })} + /> + + {effects.map((effect, index) => ( + + ))} + + + ); +}); + +const styles = StyleSheet.create({ + pageClip: { + flex: 1, + overflow: 'hidden', + backgroundColor: colors.surface, + }, + pageImage: { + position: 'absolute', + top: 0, + left: 0, + }, + caption: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingTop: 96, + paddingHorizontal: SCREEN_MARGIN, + paddingBottom: 36, + }, + title: { + fontFamily: SERIF, + fontSize: 42, + lineHeight: 46, + fontWeight: '600', + letterSpacing: -0.6, + color: colors.text, + }, + line: { + marginTop: 6, + fontSize: 17, + lineHeight: 23, + color: 'rgba(255, 255, 255, 0.86)', + }, + marks: { + position: 'absolute', + left: SCREEN_MARGIN, + bottom: 16, + flexDirection: 'row', + gap: 6, + }, + mark: { + width: 18, + height: 2, + borderRadius: 1, + backgroundColor: colors.text, + }, +}); diff --git a/example/src/components/catalog/OverlayCaption.tsx b/example/src/components/catalog/OverlayCaption.tsx new file mode 100644 index 0000000..82c9b0a --- /dev/null +++ b/example/src/components/catalog/OverlayCaption.tsx @@ -0,0 +1,46 @@ +import { StyleSheet, Text, View } from 'react-native'; +import { scrims, type } from '../../catalog/theme'; +import type { Effect } from '../../catalog/effects'; + +type OverlayCaptionProps = { + effect: Effect; + showLine?: boolean; +}; + +export function OverlayCaption({ + effect, + showLine = false, +}: OverlayCaptionProps) { + return ( + + + {effect.title} + + {showLine && ( + + {effect.line} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingTop: 44, + paddingHorizontal: 14, + paddingBottom: 12, + }, +}); diff --git a/example/src/components/catalog/PressableScale.tsx b/example/src/components/catalog/PressableScale.tsx new file mode 100644 index 0000000..4ddeb8b --- /dev/null +++ b/example/src/components/catalog/PressableScale.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from 'react'; +import { Pressable, type StyleProp, type ViewStyle } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated'; + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); + +const PRESSED_SCALE = 0.965; +const RELEASE_SPRING = { damping: 16, stiffness: 240 }; + +type PressableScaleProps = { + onPress: () => void; + accessibilityLabel: string; + accessibilityHint?: string; + style?: StyleProp; + children: ReactNode; +}; + +export function PressableScale({ + onPress, + accessibilityLabel, + accessibilityHint, + style, + children, +}: PressableScaleProps) { + const scale = useSharedValue(1); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + return ( + { + scale.value = withTiming(PRESSED_SCALE, { duration: 90 }); + }} + onPressOut={() => { + scale.value = withSpring(1, RELEASE_SPRING); + }} + style={[style, animatedStyle]} + > + {children} + + ); +} diff --git a/example/src/components/catalog/TopBar.tsx b/example/src/components/catalog/TopBar.tsx new file mode 100644 index 0000000..be45879 --- /dev/null +++ b/example/src/components/catalog/TopBar.tsx @@ -0,0 +1,182 @@ +import { BlurView } from 'expo-blur'; +import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; +import { SymbolView, type SFSymbol } from 'expo-symbols'; +import type { ReactNode } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import Animated, { + interpolate, + useAnimatedStyle, + type SharedValue, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { colors, SCREEN_MARGIN, scrims, SERIF } from '../../catalog/theme'; +import { SearchGlyph } from './Glyphs'; + +const BAR_HEIGHT = 52; +const CONTROL_SIZE = 44; +const SCROLL_EDGE_FADE = 28; +const SYSTEM_MARGIN = 16; + +type TopBarProps = { + scrollY: SharedValue; + solidAfter: number; + onSearch: () => void; + onAbout: () => void; +}; + +function GlassCircle({ children }: { children: ReactNode }) { + if (isLiquidGlassAvailable()) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} + +type ActionProps = { + label: string; + symbol: SFSymbol; + fallback: ReactNode; + onPress: () => void; +}; + +function Action({ label, symbol, fallback, onPress }: ActionProps) { + return ( + + + + + + ); +} + +export function TopBar({ + scrollY, + solidAfter, + onSearch, + onAbout, +}: TopBarProps) { + const insets = useSafeAreaInsets(); + + const scrollEdgeStyle = useAnimatedStyle(() => ({ + opacity: interpolate( + scrollY.value, + [solidAfter - 70, solidAfter], + [0, 1], + 'clamp' + ), + })); + + return ( + + + + + + Effects + + + } + onPress={onSearch} + /> + i} + onPress={onAbout} + /> + + + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, + scrim: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, + scrollEdge: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + experimental_backgroundImage: + 'linear-gradient(to bottom, rgba(0,0,0,0.96) 0%, rgba(0,0,0,0.96) 78%, rgba(0,0,0,0) 100%)', + }, + bar: { + height: BAR_HEIGHT, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingLeft: SCREEN_MARGIN, + paddingRight: SYSTEM_MARGIN, + }, + wordmark: { + fontFamily: SERIF, + fontSize: 23, + fontWeight: '600', + letterSpacing: -0.2, + color: colors.text, + }, + actions: { + flexDirection: 'row', + gap: 8, + }, + circle: { + width: CONTROL_SIZE, + height: CONTROL_SIZE, + borderRadius: CONTROL_SIZE / 2, + overflow: 'hidden', + alignItems: 'center', + justifyContent: 'center', + }, + infoFallback: { + fontFamily: SERIF, + fontSize: 20, + fontWeight: '600', + fontStyle: 'italic', + color: colors.text, + }, +}); diff --git a/example/src/components/catalog/layouts.tsx b/example/src/components/catalog/layouts.tsx new file mode 100644 index 0000000..7507389 --- /dev/null +++ b/example/src/components/catalog/layouts.tsx @@ -0,0 +1,382 @@ +import { memo } from 'react'; +import { + FlatList, + Image, + ScrollView, + StyleSheet, + Text, + useWindowDimensions, + View, + type ImageStyle, + type StyleProp, +} from 'react-native'; +import { colors, SCREEN_MARGIN, type } from '../../catalog/theme'; +import type { Effect } from '../../catalog/effects'; +import { EffectRow } from './EffectRow'; +import { OverlayCaption } from './OverlayCaption'; +import { PressableScale } from './PressableScale'; + +const POSTER_ASPECT = 0.62; +const POSTER_GAP = 12; +const POSTER_WIDTH_RATIO = 0.6; +const TILE_SIZE = 148; +const ORB_SIZE = 132; +const LEAD_ASPECT = 1.15; +const WIDE_ASPECT = 16 / 10; +const WALL_GAP = 2; +const WALL_ASPECTS = [ + [0.72, 1.05, 0.8, 0.9, 1, 0.76], + [1.05, 0.74, 0.95, 0.72, 0.84, 1.1], +]; + +export type LayoutProps = { + effects: Effect[]; + onOpen: (effect: Effect) => void; +}; + +function EffectImage({ + effect, + style, +}: { + effect: Effect; + style: StyleProp; +}) { + return ; +} + +function chunkPairs(items: T[]) { + const pairs: T[][] = []; + for (let index = 0; index < items.length; index += 2) { + pairs.push(items.slice(index, index + 2)); + } + return pairs; +} + +export const PosterRail = memo(function PosterRail({ + effects, + onOpen, +}: LayoutProps) { + const { width } = useWindowDimensions(); + const posterWidth = Math.round(width * POSTER_WIDTH_RATIO); + + return ( + effect.id} + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.railContent} + snapToInterval={posterWidth + POSTER_GAP} + decelerationRate="fast" + initialNumToRender={3} + renderItem={({ item }) => ( + onOpen(item)} + accessibilityLabel={item.title} + accessibilityHint={item.line} + style={{ width: posterWidth }} + > + + + {item.title} + + + {item.line} + + + )} + ItemSeparatorComponent={PosterGap} + /> + ); +}); + +function PosterGap() { + return ; +} + +export const Diptych = memo(function Diptych({ effects, onOpen }: LayoutProps) { + return ( + + {effects.map((effect, index) => ( + onOpen(effect)} + accessibilityLabel={effect.title} + accessibilityHint={effect.line} + style={index === 0 ? styles.diptychWide : styles.diptychNarrow} + > + + + + ))} + + ); +}); + +export const LeadStory = memo(function LeadStory({ + effects, + onOpen, +}: LayoutProps) { + const { width } = useWindowDimensions(); + const [lead, ...rest] = effects; + if (!lead) { + return null; + } + + return ( + + onOpen(lead)} + accessibilityLabel={lead.title} + accessibilityHint={lead.line} + > + + + + + + + + ); +}); + +export const TileRail = memo(function TileRail({ + effects, + onOpen, +}: LayoutProps) { + return ( + + {chunkPairs(effects).map((pair, column) => ( + + {pair.map((effect) => ( + onOpen(effect)} + accessibilityLabel={effect.title} + accessibilityHint={effect.line} + style={styles.tile} + > + + + {effect.title} + + + ))} + + ))} + + ); +}); + +export const WideStack = memo(function WideStack({ + effects, + onOpen, +}: LayoutProps) { + const { width } = useWindowDimensions(); + const imageWidth = width - SCREEN_MARGIN * 2; + + return ( + + {effects.map((effect) => ( + onOpen(effect)} + accessibilityLabel={effect.title} + accessibilityHint={effect.line} + style={styles.wide} + > + + + + ))} + + ); +}); + +export const OrbRail = memo(function OrbRail({ effects, onOpen }: LayoutProps) { + return ( + + {effects.map((effect) => ( + onOpen(effect)} + accessibilityLabel={effect.title} + accessibilityHint={effect.line} + style={styles.orb} + > + + + {effect.title} + + + ))} + + ); +}); + +export const EffectRows = memo(function EffectRows({ + effects, + onOpen, +}: LayoutProps) { + return ( + + {effects.map((effect, index) => ( + + ))} + + ); +}); + +export const LightWall = memo(function LightWall({ + effects, + onOpen, +}: LayoutProps) { + const { width } = useWindowDimensions(); + const columnWidth = (width - WALL_GAP) / 2; + const columns = [ + effects.filter((_, index) => index % 2 === 0), + effects.filter((_, index) => index % 2 === 1), + ]; + + return ( + + {columns.map((column, columnIndex) => ( + + {column.map((effect, index) => ( + onOpen(effect)} + accessibilityLabel={effect.title} + accessibilityHint={effect.line} + > + + + + ))} + + ))} + + ); +}); + +const styles = StyleSheet.create({ + image: { + width: '100%', + backgroundColor: colors.surface, + }, + fill: { + height: '100%', + }, + rounded: { + borderRadius: 16, + }, + railContent: { + paddingHorizontal: SCREEN_MARGIN, + }, + caption: { + marginTop: 10, + }, + posterGap: { + width: POSTER_GAP, + }, + diptych: { + flexDirection: 'row', + gap: WALL_GAP, + aspectRatio: 1.05, + }, + diptychWide: { + flex: 3, + }, + diptychNarrow: { + flex: 2, + }, + leadRows: { + marginTop: 8, + }, + tileColumn: { + gap: 16, + marginRight: 12, + }, + tileColumnDrop: { + marginTop: 28, + }, + tile: { + width: TILE_SIZE, + }, + tileImage: { + height: TILE_SIZE, + borderRadius: 14, + }, + wideStack: { + gap: 12, + paddingHorizontal: SCREEN_MARGIN, + }, + wide: { + borderRadius: 16, + overflow: 'hidden', + }, + orb: { + width: ORB_SIZE, + marginRight: 16, + alignItems: 'center', + }, + orbImage: { + width: ORB_SIZE, + height: ORB_SIZE, + borderRadius: ORB_SIZE / 2, + }, + orbTitle: { + marginTop: 10, + textAlign: 'center', + }, + wall: { + flexDirection: 'row', + gap: WALL_GAP, + }, + wallColumn: { + flex: 1, + gap: WALL_GAP, + }, +}); diff --git a/example/src/components/cigarette/Cigarette.tsx b/example/src/components/cigarette/Cigarette.tsx deleted file mode 100644 index 934bc15..0000000 --- a/example/src/components/cigarette/Cigarette.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import type { ViewProps } from 'react-native'; -import { ShaderView, type ParamsSynchronizable } from 'react-native-effects'; - -/** The photo's proportions: length over width. */ -export const CIG_ASPECT = 3074 / 512; -/** Where the filter ends and the tobacco begins, as a fraction of the length. */ -export const FILTER_FRAC = 0.356; -/** - * The view is taller than the cigarette so the ash has somewhere to grow - * above the tip: the photo occupies the bottom `1 / HEADROOM` of it. - */ -export const HEADROOM = 1.25; -/** - * ...and wider, so a piece of ash can tumble off to the side while still - * being drawn IN FRONT of the paper: the cigarette sits in the middle third. - */ -export const PANE_SCALE = 3; - -const CIGARETTE_PHOTO = require('../../../assets/cigarette.png'); - -type Props = ViewProps & { - paramsSynchronizable: ParamsSynchronizable; -}; - -/** - * The cigarette itself: a photo of one, burnt down by the shader. - * - * Every pixel is decided in cigarette space (v = 0 at the filter's end, v = 1 - * at the tobacco tip). Below the burn line the photo shows through, charred - * for the last few millimetres; on the line sits the coal — a crust of ash - * cells with fire in the cracks between them; above it stands the ash column, - * the same crust gone cold and grey; above the ash there is nothing. The - * burn line moves down the tobacco as the sim's `burn` advances, so the whole - * thing shortens for free. A knocked-off piece of ash is drawn on top as it - * falls, along whatever direction the phone says is down. - * - * Transparent, so the smoke pass behind shows around it — which means the - * output has to be premultiplied. - */ -export default function Cigarette({ - paramsSynchronizable, - ...viewProps -}: Props) { - return ( - - ); -} - -const CIGARETTE_SHADER = /* wgsl */ ` -struct Uniforms { - resolution: vec4, - time: vec4, - color0: vec4, - color1: vec4, - params0: vec4, - params1: vec4, - live: vec4, - liveData: array, 96>, -}; -@group(0) @binding(0) var u: Uniforms; -@group(0) @binding(1) var samp: sampler; -@group(0) @binding(2) var tex: texture_2d; - -const FILTER_FRAC = ${FILTER_FRAC}; -const HEADROOM = ${HEADROOM}; -const PANE_SCALE = ${PANE_SCALE}; -const CIG_ASPECT = ${CIG_ASPECT}; -const TOBACCO = 1.0 - FILTER_FRAC; -// Half the cigarette's width, in lengths. -const HW = 0.5 / CIG_ASPECT; -// How long a dropped piece of ash is in the air (s) — keep in step with the hook. -const CHUNK_DURATION = 1.6; - -fn hash21(p: vec2) -> f32 { - var p3 = fract(vec3(p.xyx) * 0.1031); - p3 = p3 + dot(p3, p3.yzx + 33.33); - return fract((p3.x + p3.y) * p3.z); -} - -fn vnoise(p: vec2) -> f32 { - let i = floor(p); - let f = fract(p); - let w = f * f * (3.0 - 2.0 * f); - let a = hash21(i); - let b = hash21(i + vec2(1.0, 0.0)); - let c = hash21(i + vec2(0.0, 1.0)); - let d = hash21(i + vec2(1.0, 1.0)); - return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); -} - -// The ash crust, shared by the column on the cigarette and the piece that -// falls off it. cp is the crust-space coordinate (~14 units across the -// paper), h the height above the coal (lengths), heat how hard the coal is -// burning, shade the cylinder's shading across it. -// -// Not tiles: a real coal is open glowing tobacco with soft-edged flakes of -// grey ash lying over it, and the flakes take over as it cools further up. -fn crust(cp: vec2, h: f32, heat: f32, shade: f32, emberCol: vec3) -> vec3 { - let n1 = vnoise(cp * 0.55); - let n2 = vnoise(cp * 1.3 + 7.0); - let n3 = vnoise(cp * 2.6 + 3.0); - let g = clamp(exp(-max(h, 0.0) * 24.0) * heat * 1.5, 0.0, 1.0); - - // Pale ash, lighter than you think — it is nearly white in daylight. - let lit = 0.62 + 0.38 * shade; - var col = vec3(0.87, 0.86, 0.83) * (0.86 + 0.14 * n2) * (0.94 + 0.06 * n3) * lit; - // Charcoal cracks between the flakes: dark grey, never brown. - let crack = smoothstep(0.42, 0.30, n2 * 0.7 + n3 * 0.3); - col = mix(col, vec3(0.26, 0.25, 0.24) * lit, crack * 0.75); - - // The fire: it shows first in the cracks, then the whole face goes orange - // near the paper, with a few flakes left dark on top of it. - let coal = emberCol * (0.85 + 0.30 * n3); - // Either dark or orange — a half-lit crack is brown, and brown is wrong. - let crackFire = smoothstep(0.25, 0.60, crack * g * 1.8); - let faceFire = smoothstep(0.30, 0.80, g); - let darkFlake = smoothstep(0.62, 0.78, n1) * 0.7; - let fire = clamp(crackFire + faceFire * (1.0 - darkFlake), 0.0, 1.0); - col = mix(col, coal, fire); - return col; -} - -@fragment -fn main(@location(0) ndc: vec2) -> @location(0) vec4 { - let uv = ndc * 0.5 + 0.5; - - let ember = clamp(u.live.x, 0.0, 1.0); - let draw = clamp(u.live.y, 0.0, 1.0); - let tilt = u.live.w; - let phase = u.liveData[0].x; - let burn = clamp(u.liveData[0].y, 0.0, 1.0); - let ash = clamp(u.liveData[0].z, 0.0, 1.0); - let chunkAge = clamp(u.liveData[1].y, 0.0, 1.0); - let flare = clamp(u.liveData[1].z, 0.0, 1.0); - let vis = clamp(u.liveData[1].w, 0.0, 1.0); - let chunkLen = clamp(u.liveData[2].x, 0.0, 1.0); - let chunkKick = clamp(u.liveData[2].y, -1.0, 1.0); - - // Cigarette space: v runs 0 at the filter's end to 1 at the tobacco tip, - // x runs -1..1 across the paper. P is the same thing isotropic, in lengths. - let uu = (uv.x - 0.5) * PANE_SCALE; - let x = uu * 2.0; - let v = uv.y * HEADROOM; - let P = vec2(uu / CIG_ASPECT, v); - - // The photo, where there is one. - var tc = vec4(0.0); - if (v <= 1.0 && abs(uu) < 0.5) { - tc = textureSampleLevel(tex, samp, vec2(uu + 0.5, 1.0 - v), 0.0); - } - - // Cylinder shading across the paper; the photo already has its own, the - // ash needs it painted on. - let shade = 0.50 + 0.50 * sqrt(max(1.0 - x * x * 0.92, 0.0)); - - // The burn line: a ragged edge, keyed to the burn so it changes shape as - // the coal creeps rather than every frame, and the coal is a shallow dome. - let burnLine = 1.0 - burn * TOBACCO; - let rag = (vnoise(vec2(x * 2.6 + 4.0, burn * 60.0)) - 0.5) * 0.012; - let vB = burnLine + rag + 0.008 * (1.0 - x * x); - // A crust of coal stays even right after the ash is knocked off. - let ashLen = max(ash * TOBACCO, 0.014); - let topRag = (vnoise(vec2(x * 3.1 + 9.0, burn * 45.0 + 3.0)) - 0.5) * 0.02; - let vTop = vB + ashLen + topRag; - - // Coal brightness: a slow breath, a fast shimmer under a drag, and the - // flare of lighting up or of a freshly bared coal. - let breath = 0.70 + 0.30 * vnoise(vec2(phase * 1.8, 2.0)); - let shimmer = vnoise(vec2(phase * 11.0 + x * 3.0, 7.0)); - let heat = ember * (breath * (0.62 + 0.38 * draw) + draw * 0.40 * shimmer) - + flare * 0.9; - // Saturated orange even at rest — a coal seen through grey flakes must - // not average out to brown. - let emberCol = mix(vec3(0.96, 0.30, 0.03), vec3(1.0, 0.74, 0.26), - clamp(heat - 0.35, 0.0, 1.0)); - - // Cell space for the crust: ~14 cells across the paper, square. - let cp = vec2(x * 7.0, v * 84.0); - - var col = vec3(0.0); - var alpha = 0.0; - - if (abs(uu) < 0.5) { - if (v < vB) { - // ---- Paper (and filter): the photo, charred toward the coal. ---- - col = tc.rgb; - alpha = tc.a; - let d = vB - v; - let charN = vnoise(vec2(x * 4.0 + 1.0, v * 90.0)); - // A thin black ring where the paper is burning, a faint tan just - // below it — not a wide brown smear. - let char = smoothstep(0.016, 0.0, d + (charN - 0.5) * 0.010); - let scorch = smoothstep(0.045, 0.0, d + (charN - 0.5) * 0.03); - col = mix(col, vec3(0.62, 0.48, 0.30), scorch * 0.35); - col = mix(col, vec3(0.06, 0.05, 0.04), char * 0.95); - // The paper's edge is itself on fire: a thin bright rim, then the - // coal's light falling on the char below it. - col = col + emberCol * heat * (0.9 * exp(-d * 260.0) + 0.22 * exp(-d * 50.0)); - // Sparks along the rim under a drag. - let sp = hash21(floor(vec2(x * 30.0, d * 900.0)) + floor(phase * 24.0)); - col = col + vec3(1.0, 0.85, 0.5) * step(0.985, sp) * draw * exp(-d * 200.0); - } else if (v < vTop) { - // ---- The coal and the ash standing on it. ---- - let h = v - vB; - let edgeN = vnoise(vec2(3.0, v * 140.0)) - 0.5; - let edgeR = 0.93 + edgeN * 0.09; - let inside = smoothstep(edgeR, edgeR - 0.05, abs(x)); - col = crust(cp, h, heat, shade, emberCol); - // Sparks in the seams under a drag. - let sp = hash21(floor(cp * 4.0) + floor(phase * 24.0)); - col = col + vec3(1.0, 0.85, 0.5) * step(0.988, sp) * draw * exp(-h * 40.0); - alpha = inside * smoothstep(vTop, vTop - 0.010, v); - } - } - - // Premultiply, and the whole butt fades during a swap. - var outC = col * alpha * vis; - var outA = alpha * vis; - - // ---- A piece of ash in the air, falling toward the real floor. ---- - if (chunkAge < 1.0 && chunkLen > 0.0) { - let t = chunkAge * CHUNK_DURATION; - let up = vec2(-sin(tilt), cos(tilt)); - let right = vec2(cos(tilt), sin(tilt)); - let len = chunkLen * TOBACCO; - let start = vec2(0.0, burnLine + 0.008 + len * 0.5); - let pos = start - up * (0.5 * 5.0 * t * t) + right * (chunkKick * 0.6 * t) - + up * (0.35 * abs(chunkKick) * t); - let rot = chunkKick * 4.0 * t + 0.8 * t; - let d = P - pos; - let cs = cos(rot); - let sn = sin(rot); - let c = vec2(d.x * cs + d.y * sn, -d.x * sn + d.y * cs); - let cx = c.x / HW; - let edgeN = vnoise(vec2(5.0, c.y * 140.0 + 40.0)) - 0.5; - let edgeR = 0.93 + edgeN * 0.09; - let insideX = smoothstep(edgeR, edgeR - 0.06, abs(cx)); - let endRag = (vnoise(vec2(cx * 3.0 + 17.0, 1.0)) - 0.5) * 0.012; - let insideY = smoothstep(len * 0.5 + 0.004, len * 0.5 - 0.004, abs(c.y) + endRag); - let cshade = 0.50 + 0.50 * sqrt(max(1.0 - cx * cx * 0.92, 0.0)); - // The end that sat on the coal keeps a fading glow. - let hc = c.y + len * 0.5; - let chunkHeat = ember * 0.8 * exp(-t * 3.5); - var ccol = crust(vec2(cx * 7.0, c.y * 84.0 + 300.0), hc, chunkHeat, cshade, emberCol); - let ca = insideX * insideY * (1.0 - smoothstep(0.70, 1.0, chunkAge)); - outC = ccol * ca + outC * (1.0 - ca); - outA = ca + outA * (1.0 - ca); - } - - return vec4(outC, outA); -} -`; diff --git a/example/src/components/cigarette/Smoke.tsx b/example/src/components/cigarette/Smoke.tsx deleted file mode 100644 index 1494b9b..0000000 --- a/example/src/components/cigarette/Smoke.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import type { ViewProps } from 'react-native'; -import { ShaderView, type ParamsSynchronizable } from 'react-native-effects'; - -type Props = ViewProps & { - /** - * `[tipX, tipY, cigWidth, tobaccoLength]`. The unburnt tobacco tip is in - * screen uv (y-up); the two sizes are fractions of the screen HEIGHT. - */ - params: number[]; - paramsSynchronizable: ParamsSynchronizable; -}; - -/** - * The room and the smoke in it. - * - * One opaque full-screen pass: the black room, the faint warmth the coal - * throws, the sidestream plume climbing off the ash, and the crumbs that - * come away with a knocked-off piece of ash. The cigarette is composited on - * top by its own view. - * - * The plume is built the way a real one is shaped. It leaves the tip as a - * LAMINAR ribbon: a thin, sharp-edged sheet that rises straight and only - * waves a little. Some way up it goes turbulent: the sheet folds over on - * itself into curls, spreads and thins out. In the shader that is one thin - * Gaussian ribbon evaluated in DOMAIN-WARPED coordinates — the warp is near - * zero at the tip and grows with height, so the same ribbon is a clean - * thread low down and a tangle of folded veils higher up, and the veils are - * bright where the fold compresses the sheet, exactly like edge-on smoke. - * The whole frame is rotated by the phone's tilt, so "up" is the real up. - * - * Budget: two 3-octave fbm samples for the warp field, reused by every - * layer, and everything is skipped outside the plume's bounding box. - */ -export default function Smoke({ - params, - paramsSynchronizable, - ...viewProps -}: Props) { - return ( - - ); -} - -const SMOKE_SHADER = /* wgsl */ ` -struct Uniforms { - resolution: vec4, - time: vec4, - color0: vec4, - color1: vec4, - params0: vec4, - params1: vec4, - live: vec4, - liveData: array, 96>, -}; -@group(0) @binding(0) var u: Uniforms; - -// Keep in step with the hook. -const CHUNK_DURATION = 1.6; - -fn hash21(p: vec2) -> f32 { - var p3 = fract(vec3(p.xyx) * 0.1031); - p3 = p3 + dot(p3, p3.yzx + 33.33); - return fract((p3.x + p3.y) * p3.z); -} - -fn vnoise(p: vec2) -> f32 { - let i = floor(p); - let f = fract(p); - let w = f * f * (3.0 - 2.0 * f); - let a = hash21(i); - let b = hash21(i + vec2(1.0, 0.0)); - let c = hash21(i + vec2(0.0, 1.0)); - let d = hash21(i + vec2(1.0, 1.0)); - return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); -} - -fn fbm(p0: vec2) -> f32 { - var p = p0; - var v = 0.0; - var a = 0.5; - let m = mat2x2(1.6, 1.2, -1.2, 1.6); - for (var i = 0; i < 3; i = i + 1) { - v = v + a * vnoise(p); - p = m * p; - a = a * 0.5; - } - return v; -} - -// The ash crust, shared by the column on the cigarette and the piece that -// falls off it. cp is the crust-space coordinate (~14 units across the -// paper), h the height above the coal (lengths), heat how hard the coal is -// burning, shade the cylinder's shading across it. -// -// Not tiles: a real coal is open glowing tobacco with soft-edged flakes of -// grey ash lying over it, and the flakes take over as it cools further up. -fn crust(cp: vec2, h: f32, heat: f32, shade: f32, emberCol: vec3) -> vec3 { - // Three scales of flake, none finer than a few pixels — the finest layer - // is what read as pixel grit on a phone. - let n1 = vnoise(cp * 0.55); - let n2 = vnoise(cp * 1.3 + 7.0); - let n3 = vnoise(cp * 2.6 + 3.0); - let flakeN = n1 * 0.50 + n2 * 0.35 + n3 * 0.15; - let glow = exp(-max(h, 0.0) * 30.0) * clamp(heat * 1.6, 0.0, 1.2); - let g = clamp(glow, 0.0, 1.0); - - // What shows between the flakes: dark ash seams when cold, coal when hot, - // with brighter grains where the tobacco is burning hardest. - let seam = vec3(0.36, 0.34, 0.32) * shade * (0.8 + 0.4 * n2); - var coal = emberCol * (0.70 + 0.55 * n2) * (0.85 + 0.15 * shade); - coal = coal + vec3(0.35, 0.18, 0.02) * smoothstep(0.62, 0.90, n3) * g; - let open = mix(seam, coal, g); - - // The flakes: pale grey, lit from below where they lie on the coal, and a - // few of them charred black right at the fire. - var flake = vec3(0.74, 0.72, 0.69) * (0.82 + 0.18 * n2) * (0.92 + 0.08 * n3) * shade; - flake = mix(flake, emberCol * 0.95, g * 0.40 * (1.0 - n1)); - let charred = smoothstep(0.40, 0.22, n2) * g; - flake = mix(flake, vec3(0.14, 0.11, 0.10), charred * 0.85); - - // More of the surface is bare coal the hotter it is; soft edges, always. - let th = 0.62 - 0.32 * g; - let cover = smoothstep(th - 0.13, th + 0.13, flakeN); - return mix(open, flake, cover); -} - -// One ribbon of smoke: a thin Gaussian around a centreline that waves more -// the higher it goes. q is the (warped) plume-space point, w the -// ribbon's half-width there. -fn ribbon(q: vec2, w: f32, phase: f32, seed: f32) -> f32 { - let h = max(q.y, 0.0); - // Sinuous instability: a small wave at the tip that grows with height, - // travelling upward with the smoke. - let amp = 0.002 + 0.035 * smoothstep(0.04, 0.45, h); - let wave = sin(h * 17.0 - phase * 3.4 + seed) * amp; - // A slow meander on top, so the column drifts as a whole. - let meander = (vnoise(vec2(h * 1.6 - phase * 0.35, 5.0 + seed)) - 0.5) * 0.14 * h; - let dx = (q.x - wave - meander) / w; - // A sheet seen side-on is brighter at its rims than through its middle. - return exp(-dx * dx) * (0.45 + 1.0 * dx * dx); -} - -@fragment -fn main(@location(0) ndc: vec2) -> @location(0) vec4 { - let aspect = u.resolution.z; - let uv = ndc * 0.5 + 0.5; - - let tip = u.params0.xy; - let cigW = max(u.params0.z, 0.001); - let tobacco = max(u.params0.w, 0.001); - - let ember = clamp(u.live.x, 0.0, 1.0); - let draw = clamp(u.live.y, 0.0, 1.0); - let puff = clamp(u.live.z, 0.0, 1.0); - let tilt = u.live.w; - let phase = u.liveData[0].x; - let burn = clamp(u.liveData[0].y, 0.0, 1.0); - let ash = clamp(u.liveData[0].z, 0.0, 1.0); - let puffAge = clamp(u.liveData[0].w, 0.0, 1.0); - let chunkAge = clamp(u.liveData[1].y, 0.0, 1.0); - let flare = clamp(u.liveData[1].z, 0.0, 1.0); - let vis = clamp(u.liveData[1].w, 0.0, 1.0); - let chunkLen = clamp(u.liveData[2].x, 0.0, 1.0); - let chunkKick = clamp(u.liveData[2].y, -1.0, 1.0); - - // Where the coal is now, and where the ash ends above it: that is where - // the smoke comes off. - let burnY = tip.y - burn * tobacco; - let ashTopY = burnY + max(ash * tobacco, 0.014 * tobacco); - - // Plume space: origin on top of the ash, ONE unit = the screen's height, - // rotated so y runs along the real up. - let p0 = vec2((uv.x - tip.x) * aspect, uv.y - ashTopY); - let up = vec2(-sin(tilt), cos(tilt)); - let right = vec2(cos(tilt), sin(tilt)); - let p = vec2(dot(p0, right), dot(p0, up)); - - let breath = 0.70 + 0.30 * vnoise(vec2(phase * 1.8, 2.0)); - let heat = (ember * breath * (0.42 + 0.58 * draw) + flare * 0.9) * vis; - - // ---- The room: black. The coal warms the wall a little, nothing more. ---- - var col = vec3(0.006, 0.006, 0.008); - let lp = p0 - vec2(0.0, burnY - ashTopY); - let ld2 = dot(lp, lp); - col = col + vec3(1.00, 0.40, 0.10) * heat * 0.10 / (1.0 + ld2 * 420.0); - - // ---- The plume. ---- - let source = (ember * (0.65 + 0.35 * breath) * (1.0 - 0.75 * draw)) * vis; - let puffPos = 0.02 + puffAge * 0.9; - let puffBand = exp(-pow((p.y - puffPos) / (0.06 + 0.20 * puffAge), 2.0)) * puff; - if ((source > 0.003 || puffBand > 0.003) && p.y > -0.03 && p.y < 1.05 && abs(p.x) < 0.55) { - let h = max(p.y, 0.0); - - // Laminar at the tip, turbulent above: the warp's amplitude is what - // makes the difference, and an exhale surge kicks it up early. - let turb = smoothstep(0.05, 0.35, h) + puffBand * 0.6; - let wAmp = 0.005 + 0.075 * turb; - - // The warp field, scrolled upward with the smoke. Two fbm samples make - // a vector field; a second, finer warp of that field adds the small - // curls inside the big folds. - let rise = phase * 0.5; - let q0 = vec2(p.x, h - rise) * 7.0; - let d1 = vec2(fbm(q0 + vec2(3.1, 7.3)), fbm(q0 + vec2(9.7, 1.3))) - 0.5; - let q1 = q0 * 2.5 + d1 * 1.5; - let d2 = vec2(vnoise(q1 + vec2(11.0, 4.0)), vnoise(q1 + vec2(2.0, 17.0))) - 0.5; - let warp = d1 * wAmp * 2.0 + d2 * wAmp * 0.9 * smoothstep(0.10, 0.5, h); - let q = vec2(p.x + warp.x, h + warp.y * 0.8); - - // Two ribbons out of phase — a real plume is a few sheets, not one — - // their width opening slowly with height and fattening in a surge. - let w = cigW * 0.15 * (1.0 + 2.2 * q.y) + puffBand * 0.015; - let r1 = ribbon(q, w, phase, 0.0); - let r2 = ribbon(q + vec2(cigW * 0.08, 0.0), w * 0.7, phase, 2.1) * 0.6; - // A soft body of diffuse smoke around the sheets once it has mixed. - let body = exp(-pow(q.x / (w * 4.0), 2.0)) * 0.08 * smoothstep(0.12, 0.5, h); - - // Thins as it climbs and mixes into the room. - let fade = smoothstep(-0.015, 0.008, p.y) * (1.0 - smoothstep(0.5, 0.95, h)) / (1.0 + 1.2 * h); - var dens = (r1 + r2 + body) * fade; - dens = dens * (source + puffBand * 1.4); - - // Sidestream smoke is BLUE: fine particles scatter the short wavelengths. - // It goes white where sheets pile up, and warm right above the coal. - let sa = clamp(dens * 0.75, 0.0, 0.88); - var smokeCol = mix(vec3(0.45, 0.60, 0.90), vec3(0.93, 0.96, 1.0), smoothstep(0.15, 0.8, sa)); - smokeCol = mix(smokeCol, vec3(0.98, 0.78, 0.55), heat * exp(-h * 40.0) * 0.5); - // Screen blend: lighter where sheets overlap, never past white. - col = col + smokeCol * sa * (1.0 - col); - } - - // ---- The knocked-off piece of ash, and the crumbs that come with it. ---- - // The cigarette pane draws the same piece in front of the paper; this copy - // behind it is what keeps it in the picture once it has tumbled out of - // that narrow pane. - if (chunkAge < 1.0 && chunkLen > 0.0) { - let t = chunkAge * CHUNK_DURATION; - let origin = vec2(0.0, burnY - ashTopY); - // Everything here is in screen-height units; the cigarette's length is - // the yardstick the pane uses, so convert. - let cigL = tobacco / 0.644; - let hw = cigW * 0.5; - let len = chunkLen * tobacco; - let breathC = 0.70 + 0.30 * vnoise(vec2(phase * 1.8, 2.0)); - let heatC = ember * breathC * 0.62 + flare * 0.9; - let emberCol = mix(vec3(0.80, 0.12, 0.01), vec3(1.0, 0.66, 0.20), - clamp(heatC - 0.30, 0.0, 1.0)); - let pos = origin + up * (0.008 * cigL + len * 0.5) - - up * (0.5 * 5.0 * t * t * cigL) + right * (chunkKick * 0.6 * t * cigL) - + up * (0.35 * abs(chunkKick) * t * cigL); - let rot = chunkKick * 4.0 * t + 0.8 * t; - let d = p0 - pos; - // Into the piece's own frame: first undo the tilt, then its tumble. - let dl = vec2(dot(d, right), dot(d, up)); - let cs = cos(rot); - let sn = sin(rot); - let c = vec2(dl.x * cs + dl.y * sn, -dl.x * sn + dl.y * cs); - let cx = c.x / hw; - let cyL = c.y / cigL; - let edgeN = vnoise(vec2(5.0, cyL * 140.0 + 40.0)) - 0.5; - let edgeR = 0.93 + edgeN * 0.09; - let insideX = smoothstep(edgeR, edgeR - 0.06, abs(cx)); - let endRag = (vnoise(vec2(cx * 3.0 + 17.0, 1.0)) - 0.5) * 0.012 * cigL; - let insideY = smoothstep(len * 0.5 + 0.004 * cigL, len * 0.5 - 0.004 * cigL, abs(c.y) + endRag); - let cshade = 0.50 + 0.50 * sqrt(max(1.0 - cx * cx * 0.92, 0.0)); - let hc = (c.y + len * 0.5) / cigL; - let chunkHeat = ember * 0.8 * exp(-t * 3.5); - let ccol = crust(vec2(cx * 7.0, cyL * 84.0 + 300.0), hc, chunkHeat, cshade, emberCol); - let ca = insideX * insideY * (1.0 - smoothstep(0.70, 1.0, chunkAge)); - col = mix(col, ccol, ca); - for (var i = 0; i < 6; i = i + 1) { - let fi = f32(i); - let h1 = hash21(vec2(fi, 1.0)); - let h2 = hash21(vec2(fi, 2.0)); - let h3 = hash21(vec2(fi, 3.0)); - let side = select(-1.0, 1.0, h1 > 0.5); - let vx = side * (0.03 + 0.08 * h2) + chunkKick * 0.10; - let fpos = origin + right * (cigW * 0.35 * side + vx * t) - + up * ((ashTopY - burnY) * h3 + 0.02 * h2 * t - (0.14 + 0.16 * h1) * t * t); - let r = length(p0 - fpos); - let sz = 0.0018 + 0.0022 * h3; - let flake = smoothstep(sz, sz * 0.3, r) * (1.0 - smoothstep(0.6, 1.0, chunkAge)); - let glowF = smoothstep(0.35, 0.0, chunkAge) * ember; - let fcol = mix(vec3(0.50, 0.48, 0.46), vec3(1.0, 0.45, 0.10), glowF * h2); - col = mix(col, fcol, flake); - } - } - - // ---- Frame: the faintest vignette, and dither so the black stays clean. ---- - let vd = (uv - vec2(0.5, 0.45)) * vec2(1.10, 1.00); - col = col * (1.0 - clamp(dot(vd, vd), 0.0, 1.0) * 0.5); - col = col + (hash21(uv * u.resolution.xy) - 0.5) * (2.0 / 255.0); - - return vec4(clamp(col, vec3(0.0), vec3(1.0)), 1.0); -} -`; diff --git a/example/src/components/cracks/CrackedGlass.tsx b/example/src/components/cracks/CrackedGlass.tsx new file mode 100644 index 0000000..0cba2e7 --- /dev/null +++ b/example/src/components/cracks/CrackedGlass.tsx @@ -0,0 +1,571 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +export const MAX_IMPACTS = 6; +/** `u.live` + two vec4 per impact in `u.liveData`. */ +export const CRACK_CHANNEL_LENGTH = 4 + MAX_IMPACTS * 8; + +type CrackedGlassProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'transparent' | 'texture' +> & { + /** Snapshot of the content under the glass, sized like this view. */ + textureUri: string; + /** + * `u.live = (impactCount, tiltX, tiltY, 0)`, then per impact + * `(x, y, ageSeconds, seed), (strength, deadPixels, 0, 0)` in logical px, + * y-down. + */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A shattered cover glass over a snapshot of the screen. Each impact jolts + * the screen, flashes and throws glass dust, then races kinked radial cracks + * with side branches out from a crushed centre and lets polygonal ring cracks + * run between neighbouring rays. Every shard (sector between two rays x band + * between two rings) refracts the content by its own offset with a chromatic + * fringe at its edges, and crack segments facing the light glint as the phone + * tilts. Some impacts also kill the panel underneath: a spreading ink blot + * and a dead pixel column. + */ +export default function CrackedGlass({ + textureUri, + paramsSynchronizable, + ...rest +}: CrackedGlassProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, + liveData: array, 96>, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +const TAU: f32 = 6.2831853; +const PI: f32 = 3.14159265; +const MAX_IMPACTS: i32 = 6; +const RAYS: i32 = 17; +const SPLINTERS: i32 = 43; +const RINGS: i32 = 4; +const REACH: f32 = 980.0; // longest radial crack, logical px +const SPEED: f32 = 3400.0; // crack front speed, logical px per second +const CORE: f32 = 30.0; // crushed centre radius, logical px +const RAY_SEG: f32 = 34.0; +const BRANCH_SEG: f32 = 22.0; +const DEBRIS: i32 = 22; + +fn hash21(p: vec2) -> f32 { + var p3 = fract(vec3(p.xyx) * 0.1031); + p3 = p3 + dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} +fn hash22(p: vec2) -> vec2 { + var p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973)); + p3 = p3 + dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); +} +fn wrapPi(a: f32) -> f32 { + return a - TAU * floor((a + PI) / TAU); +} +fn dir(a: f32) -> vec2 { + return vec2(cos(a), sin(a)); +} +fn wrapRay(j: i32) -> i32 { + return ((j % RAYS) + RAYS) % RAYS; +} +fn easeOut(x: f32) -> f32 { + let t = clamp(x, 0.0, 1.0); + return 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t); +} + +// A crack is a polyline whose nodes sit at fixed steps along a base +// direction and are pushed sideways: a random kink per node plus a slow bend +// that is zero at the origin. Kinks are sharp, like real fracture paths. +struct Polyline { + o: vec2, + d: vec2, + key: vec2, + seg: f32, + len: f32, +}; + +fn kink(p: Polyline, i: i32) -> f32 { + if (i <= 0) { + return 0.0; + } + let fi = f32(i); + let h = hash21(vec2(fi * 1.37 + p.key.x, p.key.y * 17.0 + fi)) - 0.5; + let ph = hash21(p.key + 3.1) * TAU; + return h * p.seg * 0.42 + p.seg * 0.55 * (sin(fi * 0.27 + ph) - sin(ph)); +} +fn node(p: Polyline, i: i32) -> vec2 { + let t = min(f32(i) * p.seg, p.len); + return p.o + p.d * t + vec2(-p.d.y, p.d.x) * kink(p, i); +} +fn pointAt(p: Polyline, along: f32) -> vec2 { + let x = clamp(along, 0.0, p.len) / p.seg; + let i = i32(floor(x)); + return mix(node(p, i), node(p, i + 1), fract(x)); +} + +struct Hit { + dist: f32, + side: f32, + along: f32, + nrm: vec2, + segId: f32, +}; + +fn trace(q: vec2, p: Polyline) -> Hit { + var h: Hit; + h.dist = 1e5; + h.side = 1.0; + h.along = 0.0; + h.nrm = vec2(0.0, 1.0); + h.segId = 0.0; + let last = i32(ceil(p.len / p.seg)) - 1; + if (last < 0) { + return h; + } + let k = clamp(i32(floor(dot(q - p.o, p.d) / p.seg)), 0, last); + for (var s = k - 1; s <= k + 1; s = s + 1) { + if (s < 0 || s > last) { + continue; + } + let a = node(p, s); + let b = node(p, s + 1); + let ab = b - a; + let t = clamp(dot(q - a, ab) / max(dot(ab, ab), 0.0001), 0.0, 1.0); + let v = q - (a + ab * t); + let l = length(v); + if (l < h.dist) { + let sn = normalize(vec2(-ab.y, ab.x)); + let sd = select(-1.0, 1.0, dot(q - a, sn) >= 0.0); + h.dist = l; + h.side = sd; + h.along = (f32(s) + t) * p.seg; + h.nrm = select(sn * sd, v / l, l > 0.001); + h.segId = f32(s); + } + } + return h; +} + +struct Glass { + edge: f32, // distance to the nearest crack edge, device px + nrm: vec2, // that crack's normal, pointing at this pixel + glint: f32, // how strongly that crack segment catches the light + tone: f32, // how visible that crack segment is + offset: vec2, // refraction of the content, logical px + shade: f32, // per-shard sheen + crush: f32, // milky crushed-glass amount + dust: f32, // crushed shard borders + sparkle: f32, // crushed-cell and debris glitter + ink: f32, // dead LCD under the impact + inkEdge: f32, + stripe: vec3, // dead pixel column colour + flash: f32, +}; + +fn takeLine(g: ptr, h: Hit, width: f32, grown: f32, light: vec2, salt: f32) { + let reveal = clamp((grown - h.along) * u.resolution.w, 0.0, 1.0); + if (reveal <= 0.0) { + return; + } + let e = h.dist * u.resolution.w - width * 0.5 + (1.0 - reveal) * 2.0; + if (e < (*g).edge) { + (*g).edge = e; + (*g).nrm = h.nrm; + let facing = abs(dot(h.nrm, light)); + let lucky = hash21(vec2(h.segId, salt)); + (*g).glint = pow(facing, 10.0) * step(0.55, lucky); + let wave = 0.5 + 0.5 * sin(h.along * 0.045 + lucky * 30.0); + (*g).tone = mix(0.45, 1.0, hash21(vec2(h.segId + 0.5, salt + 1.0))) * mix(0.6, 1.0, wave); + } +} + +fn rayLine(c: vec2, j: i32, seed: f32, strength: f32) -> Polyline { + let fj = f32(j); + let h = hash22(vec2(fj, seed * 31.0)); + let ang = (fj + 0.5 + (h.x - 0.5) * 0.75) * TAU / f32(RAYS) + seed * TAU; + var p: Polyline; + p.o = c; + p.d = dir(ang); + p.key = vec2(fj * 7.3, seed * 91.0); + p.seg = RAY_SEG; + p.len = REACH * strength * mix(0.18, 1.0, h.y * h.y); + return p; +} +fn raySpeed(j: i32, seed: f32) -> f32 { + return SPEED * mix(0.65, 1.0, hash21(vec2(f32(j) + 0.7, seed * 13.0))); +} +fn branchLine(ray: Polyline, j: i32, b: i32, seed: f32) -> Polyline { + let h = hash22(vec2(f32(j) * 3.0 + f32(b), seed * 17.0 + 4.0)); + let at = ray.len * mix(0.12, 0.62, h.x); + let side = select(-1.0, 1.0, hash21(vec2(f32(j) + f32(b) * 5.0, seed)) > 0.5); + let base = atan2(ray.d.y, ray.d.x); + var p: Polyline; + p.o = pointAt(ray, at); + p.d = dir(base + side * mix(0.35, 0.95, h.y)); + p.key = vec2(f32(j) * 11.0 + f32(b) * 3.0, seed * 57.0); + p.seg = BRANCH_SEG; + p.len = ray.len * mix(0.12, 0.38, h.y) + 20.0; + return p; +} +fn ringRadius(j: i32, k: i32, seed: f32, strength: f32) -> f32 { + let fk = f32(k); + let base = CORE * strength * (2.0 + fk * 1.7 + fk * fk * 0.55); + return base * (0.84 + 0.32 * hash21(vec2(f32(j) + 0.5, fk + seed * 13.0))); +} +fn ringInSector(s: i32, k: i32, seed: f32) -> bool { + return hash21(vec2(f32(s) * 3.0 + f32(k), seed * 7.0 + 2.0)) > 0.22 + f32(k) * 0.1; +} + +fn segDist(q: vec2, a: vec2, b: vec2) -> vec2 { + let ab = b - a; + let t = clamp(dot(q - a, ab) / max(dot(ab, ab), 0.0001), 0.0, 1.0); + return vec2(length(q - (a + ab * t)), t); +} + +struct Cells { + edge: f32, + id: vec2, +}; + +fn voronoi(p: vec2, seed: f32) -> Cells { + let cell = floor(p); + var f1 = 9.0; + var f2 = 9.0; + var id = vec2(0.0); + for (var y = -1; y <= 1; y = y + 1) { + for (var x = -1; x <= 1; x = x + 1) { + let cid = cell + vec2(f32(x), f32(y)); + let pt = cid + 0.1 + 0.8 * hash22(cid + seed * 41.0); + let dd = length(p - pt); + if (dd < f1) { + f2 = f1; + f1 = dd; + id = cid; + } else if (dd < f2) { + f2 = dd; + } + } + } + var out: Cells; + out.edge = (f2 - f1) * 0.5; + out.id = id; + return out; +} + +// Crushed centre: angular shards, finer toward the point of impact. +fn crushed(g: ptr, d: vec2, r: f32, core: f32, seed: f32, light: vec2) { + let cz = 1.0 - smoothstep(core * 0.6, core * 1.4, r); + if (cz <= 0.0) { + return; + } + let fineness = smoothstep(core * 0.35, core * 0.8, r); + let cellPx = mix(3.2, 8.5, fineness); + let v = voronoi(d / cellPx, seed + floor(fineness + 0.5) * 3.0); + let edgePx = v.edge * cellPx * u.resolution.w; + let h = hash22(v.id + seed * 7.0); + let drawn = select(0.15, 0.75, h.y > 0.45); + (*g).dust = max((*g).dust, (1.0 - smoothstep(0.3, 1.1, edgePx)) * cz * drawn); + (*g).crush = max((*g).crush, cz * h.x * h.x); + (*g).offset = (*g).offset + (h - 0.5) * 7.0 * cz; + (*g).shade = (*g).shade + (h.y - 0.5) * 0.35 * cz; + let facet = normalize(h - 0.5 + vec2(0.0001)); + (*g).sparkle = max((*g).sparkle, pow(max(dot(facet, light), 0.0), 24.0) * cz * step(0.5, h.x)); +} + +fn debris(g: ptr, q: vec2, c: vec2, age: f32, seed: f32, strength: f32) { + if (age > 0.7) { + return; + } + for (var i = 0; i < DEBRIS; i = i + 1) { + let h = hash22(vec2(f32(i) * 1.9, seed * 77.0)); + let v = mix(220.0, 900.0, h.y * h.y) * strength; + let travel = v * (1.0 - exp(-age * 4.0)) / 4.0; + let p = c + dir(h.x * TAU) * (travel + 6.0); + let life = 1.0 - smoothstep(0.25, 0.7, age + h.y * 0.15); + let d = (q - p) * u.resolution.w; + let size = mix(1.6, 4.0, hash21(vec2(f32(i), seed))); + let star = exp(-dot(d, d) / (size * size)) + 0.35 * exp(-abs(d.x) * 0.9 - abs(d.y) * 0.18 / size) * exp(-abs(d.y) * 0.25) + + 0.35 * exp(-abs(d.y) * 0.9 - abs(d.x) * 0.18 / size) * exp(-abs(d.x) * 0.25); + let twinkle = 0.6 + 0.4 * sin(age * 70.0 + h.x * 40.0); + (*g).sparkle = max((*g).sparkle, star * life * twinkle); + } +} + +// Smooth random value around the circle with n knots, 0..1. +fn loopNoise(th: f32, n: f32, seed: f32) -> f32 { + let x = fract(th / TAU + 1.0) * n; + let i = floor(x); + let f = x - i; + let a = hash21(vec2(i, seed * 61.0)); + let b = hash21(vec2(i + 1.0 - n * step(n - 1.5, i), seed * 61.0)); + return mix(a, b, f * f * (3.0 - 2.0 * f)); +} + +fn deadPixels(g: ptr, q: vec2, c: vec2, r: f32, th: f32, age: f32, seed: f32, strength: f32) { + let spread = 1.0 - exp(-age * 1.8); + let wobble = loopNoise(th, 5.0, seed) * 0.32 + loopNoise(th, 13.0, seed + 0.5) * 0.12 - 0.22; + let radius = (9.0 + 34.0 * spread) * strength * (1.0 + wobble); + let inside = 1.0 - smoothstep(radius - 2.5, radius + 0.5, r); + (*g).ink = max((*g).ink, inside); + (*g).inkEdge = max((*g).inkEdge, exp(-max(r - radius * 0.7, 0.0) / (6.0 + 10.0 * spread)) * (1.0 - inside)); + + let col = hash21(vec2(seed * 3.0, 1.7)); + let x0 = c.x + (hash21(vec2(seed, 9.2)) - 0.5) * 30.0; + let w = mix(1.0, 2.2, hash21(vec2(seed, 4.4))); + let shown = smoothstep(0.15, 0.3, age); + let line = (1.0 - smoothstep(w * 0.5 - 0.5, w * 0.5 + 0.5, abs(q.x - x0))) * shown; + var tint = vec3(0.1, 1.0, 0.35); + if (col > 0.66) { + tint = vec3(0.85, 0.2, 1.0); + } else if (col > 0.33) { + tint = vec3(0.95, 0.97, 1.0); + } + (*g).stripe = max((*g).stripe, tint * line); +} + +fn impact(g: ptr, q: vec2, light: vec2, i: i32) { + let a = u.liveData[i * 2]; + let b = u.liveData[i * 2 + 1]; + let c = a.xy; + let age = a.z; + let seed = a.w; + let strength = b.x; + if (age <= 0.0) { + return; + } + + let d = q - c; + let r = length(d); + let th = atan2(d.y, d.x); + let rel = fract((th - seed * TAU) / TAU); + let core = CORE * strength * (1.0 + 0.25 * sin(th * 3.0 + seed * 20.0) + 0.12 * sin(th * 7.0 + seed * 9.0)); + let front = age * SPEED; + + (*g).flash = (*g).flash + exp(-age / 0.04) * exp(-r / 120.0) + exp(-age / 0.025) * 0.1; + debris(g, q, c, age, seed, strength); + if (b.y > 0.5) { + deadPixels(g, q, c, r, th, age, seed, strength); + } + + // Radial cracks: the nearest three, each with two side branches. + let k = i32(floor(rel * f32(RAYS))); + var centreSide = 1.0; + for (var jj = -1; jj <= 1; jj = jj + 1) { + let j = wrapRay(k + jj); + let ray = rayLine(c, j, seed, strength); + let grown = age * raySpeed(j, seed); + let h = trace(q, ray); + let taper = 1.0 - clamp(h.along / ray.len, 0.0, 1.0); + let w = (0.8 + 1.7 * taper * taper * taper) * mix(1.0, 1.6, smoothstep(core * 3.0, core, r)); + takeLine(g, h, w, grown, light, f32(j) + seed * 10.0); + if (jj == 0) { + centreSide = h.side; + } + + for (var bi = 0; bi < 2; bi = bi + 1) { + let br = branchLine(ray, j, bi, seed); + let startAt = length(br.o - c); + let bg = grown - startAt * 1.05; + if (bg > 0.0 && length(q - br.o) < br.len + br.seg) { + let hb = trace(q, br); + let bt = 1.0 - clamp(hb.along / br.len, 0.0, 1.0); + takeLine(g, hb, 0.75 + 1.1 * bt * bt, bg, light, f32(j * 2 + bi) + seed * 20.0); + } + } + } + let lower = select(wrapRay(k - 1), k, centreSide > 0.0); + let upper = wrapRay(lower + 1); + let sector = lower; + + // Ring cracks: kinked chords between neighbouring rays, so the rings read + // as the polygonal web real glass makes. Each one crossed outward starts a + // new band of shards. + let rayA = rayLine(c, lower, seed, strength); + let rayB = rayLine(c, upper, seed, strength); + var band = 0; + for (var rk = 0; rk < RINGS; rk = rk + 1) { + if (!ringInSector(sector, rk, seed)) { + continue; + } + let ra = ringRadius(lower, rk, seed, strength); + let rb = ringRadius(upper, rk, seed, strength); + if (ra > rayA.len || rb > rayB.len) { + continue; + } + if (r > max(ra, rb) * 1.6) { + band = band + 1; + continue; + } + let pa = pointAt(rayA, ra); + let pb = pointAt(rayB, rb); + let mh = hash21(vec2(f32(sector) + f32(rk) * 7.0, seed * 5.0)) - 0.5; + let pm = mix(pa, pb, 0.5 + mh * 0.3) + normalize(mix(pa, pb, 0.5) - c) * mh * length(pb - pa) * 0.22; + let s1 = segDist(q, pa, pm); + let s2 = segDist(q, pm, pb); + let firstHalf = s1.x < s2.x; + let e0 = select(pm, pa, firstHalf); + let e1 = select(pb, pm, firstHalf); + var outward = vec2(-(e1 - e0).y, (e1 - e0).x); + if (dot(outward, e0 - c) < 0.0) { + outward = -outward; + } + if (dot(q - e0, outward) > 0.0) { + band = band + 1; + } + + let startAt = max(ra / raySpeed(lower, seed), rb / raySpeed(upper, seed)) + 0.025 + f32(rk) * 0.022; + let runPx = (age - startAt) * 1800.0; + if (runPx > 0.0) { + let l1 = length(pm - pa); + let l2 = length(pb - pm); + let fromA = hash21(vec2(f32(sector), f32(rk) + seed)) > 0.5; + let pos = select(l1 + s2.y * l2, s1.y * l1, firstHalf); + let along = select(l1 + l2 - pos, pos, fromA); + var rh: Hit; + rh.dist = min(s1.x, s2.x); + rh.along = along; + let sd = q - select(mix(pm, pb, s2.y), mix(pa, pm, s1.y), firstHalf); + rh.nrm = select(normalize(outward), sd / max(rh.dist, 0.0001), rh.dist > 0.001); + rh.segId = f32(rk) + select(0.5, 0.0, firstHalf); + rh.side = 1.0; + takeLine(g, rh, 1.0 + 0.5 * f32(RINGS - rk) / f32(RINGS), runPx, light, f32(sector) + seed * 30.0); + } + } + + let settled = smoothstep(0.0, 0.08, age - r / SPEED); + let fade = smoothstep(min(REACH * strength, 800.0), 120.0, r) * settled; + let hs = hash22(vec2(f32(sector) * 5.0 + f32(band), seed * 23.0)) - 0.5; + let near = 0.3 + 0.7 * exp(-r / 240.0); + (*g).offset = (*g).offset + hs * 3.6 * strength * near * fade; + let tilt = hash22(vec2(f32(band) + 9.1, f32(sector) + seed * 3.0)) - 0.5; + (*g).shade = (*g).shade + dot(tilt, light) * 0.18 * fade; + + let fk = i32(floor(fract((th - seed * 3.1) / TAU) * f32(SPLINTERS))); + for (var jj = -1; jj <= 1 && r < core * 3.8; jj = jj + 1) { + let j = ((fk + jj) % SPLINTERS + SPLINTERS) % SPLINTERS; + let hh = hash22(vec2(f32(j) + 0.3, seed * 53.0)); + var sp: Polyline; + sp.o = c; + sp.d = dir((f32(j) + 0.5 + (hh.x - 0.5) * 0.9) * TAU / f32(SPLINTERS) + seed * 3.1); + sp.key = vec2(f32(j) * 5.1 + 40.0, seed * 19.0); + sp.seg = 9.0; + sp.len = core * mix(1.1, 3.4, hh.y * hh.y); + let hs2 = trace(q, sp); + takeLine(g, hs2, 0.6 + 0.9 * (1.0 - hs2.along / sp.len), age * SPEED * 0.5, light, f32(j) + seed * 40.0); + } + + crushed(g, d, r, core * min(1.0, 0.4 + age * 25.0), seed, light); +} + +fn shake(age: f32, seed: f32, strength: f32) -> vec2 { + if (age <= 0.0 || age > 0.5) { + return vec2(0.0); + } + let env = exp(-age * 11.0) * strength; + let a = seed * TAU; + return dir(a) * sin(age * 95.0) * 6.0 * env + dir(a + 1.7) * sin(age * 61.0 + 1.0) * 3.0 * env; +} + +fn sampleRgb(q: vec2, size: vec2, offset: vec2, spread: vec2) -> vec3 { + let lo = vec2(0.001); + let hi = vec2(0.999); + let rr = textureSampleLevel(tex, samp, clamp((q + offset + spread) / size, lo, hi), 0.0).r; + let gg = textureSampleLevel(tex, samp, clamp((q + offset) / size, lo, hi), 0.0).g; + let bb = textureSampleLevel(tex, samp, clamp((q + offset - spread) / size, lo, hi), 0.0).b; + return vec3(rr, gg, bb); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let size = u.resolution.xy / u.resolution.w; + let count = i32(u.live.x + 0.5); + let light = normalize(vec2(-0.45, -0.9) + (u.live.yz - 0.5) * vec2(2.2, -2.2)); + + var jolt = vec2(0.0); + for (var i = 0; i < MAX_IMPACTS; i = i + 1) { + if (i >= count) { + break; + } + jolt = jolt + shake(u.liveData[i * 2].z, u.liveData[i * 2].w, u.liveData[i * 2 + 1].x); + } + let q = vec2(uv.x, 1.0 - uv.y) * size + jolt; + + var g: Glass; + g.edge = 1000.0; + g.nrm = vec2(0.0, 1.0); + g.glint = 0.0; + g.tone = 1.0; + g.offset = vec2(0.0); + g.shade = 0.0; + g.crush = 0.0; + g.dust = 0.0; + g.sparkle = 0.0; + g.ink = 0.0; + g.inkEdge = 0.0; + g.stripe = vec3(0.0); + g.flash = 0.0; + for (var i = 0; i < MAX_IMPACTS; i = i + 1) { + if (i >= count) { + break; + } + impact(&g, q, light, i); + } + + let bevel = exp(-max(g.edge, 0.0) / 2.4); + let offset = g.offset - g.nrm * bevel * 1.1; + let spread = g.nrm * bevel * 1.3 + g.offset * 0.25 + vec2(g.crush * 1.5, 0.0); + var col = sampleRgb(q, size, offset, spread); + + let bleed = mix(vec3(0.05, 0.35, 0.3), vec3(0.35, 0.06, 0.45), g.inkEdge); + let inkCol = mix(bleed, vec3(0.01, 0.008, 0.015), g.ink); + col = col * (1.0 + g.shade); + let milky = vec3(0.82, 0.85, 0.88); + col = mix(col, milky, g.crush * 0.28); + col = mix(col, inkCol, max(g.ink, g.inkEdge * 0.85)); + col = max(col, g.stripe); + col = mix(col, vec3(0.96), g.dust * 0.75); + + let facing = dot(g.nrm, light); + let lit = clamp(facing, 0.0, 1.0); + let dark = clamp(-facing, 0.0, 1.0); + col = col + bevel * (lit * 0.2 - dark * 0.22); + + let coreA = 1.0 - smoothstep(-0.55, 0.65, g.edge); + let silver = mix(vec3(0.62, 0.66, 0.7), vec3(1.0), 0.45 + 0.55 * lit); + col = mix(col, silver, coreA * g.tone * 0.92); + col = col + vec3(1.0, 0.98, 0.95) * min(g.glint, 1.0) * (coreA + bevel * 0.3) * 0.6; + + col = col + vec3(1.0, 0.98, 0.94) * min(g.sparkle, 1.5); + col = col + vec3(0.9, 0.95, 1.0) * min(g.flash, 1.2); + + return vec4(clamp(col, vec3(0.0), vec3(1.0)), 1.0); +} +`; diff --git a/example/src/components/cracks/HingeFailure.tsx b/example/src/components/cracks/HingeFailure.tsx new file mode 100644 index 0000000..15ac9c8 --- /dev/null +++ b/example/src/components/cracks/HingeFailure.tsx @@ -0,0 +1,264 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +/** Where the crack starts on the hinge, as a fraction of the panel height. */ +export const CRACK_Y = 0.58; +export const FAILURE_STAGES = 4; + +type HingeFailureProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'transparent' | 'texture' +> & { + /** `u.live = (damage 0..4, glitch 0..1, 0, 0)`. */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A foldable inner display dying along its crease, as a transparent layer + * over the live screen, after the viral owner photos: a crack runs the length + * of the hinge, stuck white, magenta, green and cyan pixel columns shoot out of + * it, black ink bleeds out from the failure point, and finally the lines + * spread across the panel and the damage flickers. The hinge is the vertical + * centre line of the view. `damage` walks through the four stages, `glitch` + * is a short tearing burst played as each stage lands. + */ +export default function HingeFailure({ + paramsSynchronizable, + ...rest +}: HingeFailureProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; + +const CRACK_Y: f32 = ${CRACK_Y}; +const BLACK: vec3 = vec3(0.0, 0.0, 0.0); +const WHITE: vec3 = vec3(1.0, 1.0, 1.0); +const MAGENTA: vec3 = vec3(1.0, 0.22, 0.88); +const GREEN: vec3 = vec3(0.3, 1.0, 0.42); +const CYAN: vec3 = vec3(0.25, 0.9, 1.0); +const YELLOW: vec3 = vec3(1.0, 0.95, 0.35); + +fn hash11(x: f32) -> f32 { + return fract(sin(x * 12.9898) * 43758.5453); +} + +fn hash21(p: vec2) -> f32 { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +fn noise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let w = f * f * (3.0 - 2.0 * f); + let a = hash21(i); + let b = hash21(i + vec2(1.0, 0.0)); + let c = hash21(i + vec2(0.0, 1.0)); + let d = hash21(i + vec2(1.0, 1.0)); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); +} + +fn fbm(p: vec2) -> f32 { + return 0.55 * noise(p) + 0.3 * noise(p * 2.1 + 5.2) + 0.15 * noise(p * 4.3 + 1.7); +} + +fn palette(k: f32) -> vec3 { + if (k < 0.3) { return WHITE; } + if (k < 0.55) { return MAGENTA; } + if (k < 0.75) { return GREEN; } + if (k < 0.9) { return CYAN; } + return YELLOW; +} + +// Premultiplied "over": paints a layer of colour rgb and coverage a on top. +fn over(dst: vec4, rgb: vec3, a: f32) -> vec4 { + let k = clamp(a, 0.0, 1.0); + return vec4(rgb * k + dst.rgb * (1.0 - k), k + dst.a * (1.0 - k)); +} + +fn segDist(q: vec2, a: vec2, b: vec2) -> f32 { + let ab = b - a; + let t = clamp(dot(q - a, ab) / max(dot(ab, ab), 0.0001), 0.0, 1.0); + return length(q - (a + ab * t)); +} + +// The crack wanders a couple of points around the crease line. +fn crackX(y: f32, hx: f32) -> f32 { + return hx + 2.2 * (noise(vec2(y * 0.08, 3.1)) - 0.5) + + 0.8 * (noise(vec2(y * 0.45, 7.3)) - 0.5); +} + +// Signed distance to the ink: a blot at the failure point stretched along the +// hinge, a smaller one higher up, both with ragged noisy edges. +fn inkDist(q: vec2, hx: f32, y0: f32, size: vec2, s3: f32, s4: f32) -> f32 { + let k = clamp(size.x / 900.0, 0.6, 1.0); + let h = size.y; + let warp = (fbm(q * 0.035) - 0.5) * k; + let r1 = (30.0 * smoothstep(0.0, 1.0, s3) + 60.0 * s4) * k; + let p1 = vec2(q.x - hx, (q.y - y0) * 0.33); + let d1 = length(p1) + warp * (14.0 + r1 * 0.8) - r1; + let r2 = 26.0 * s4 * k; + let p2 = vec2(q.x - hx, (q.y - (y0 - h * 0.36)) * 0.45); + let d2 = length(p2) + warp * (10.0 + r2 * 0.8) - r2; + return min(d1, select(1000.0, d2, s4 > 0.0)); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let dpr = u.resolution.w; + let size = u.resolution.xy / dpr; + let q = vec2(uv.x, 1.0 - uv.y) * size; + let damage = u.live.x; + let t = u.time.x; + + let hx = floor(size.x * 0.5 * dpr) / dpr; + let y0 = floor(size.y * CRACK_Y * dpr) / dpr; + let dx = q.x - hx; + let vy = abs(q.y - y0); + + let s1 = clamp(damage, 0.0, 1.0); + let s2 = clamp(damage - 1.0, 0.0, 1.0); + let s3 = clamp(damage - 2.0, 0.0, 1.0); + let s4 = clamp(damage - 3.0, 0.0, 1.0); + + // Glitch: the stage burst from JS plus rare short tears once fully broken. + let slot = floor(t * 9.0); + let rare = step(0.9, hash11(slot + 0.37)) * s4 * 0.7; + let g = clamp(max(u.live.y, rare), 0.0, 1.0); + let band = floor(q.y / 12.0); + let torn = step(0.65, hash21(vec2(band + 3.0, slot))); + let tear = (hash21(vec2(band, slot)) - 0.5) * 26.0 * g * torn; + let qx = q.x + tear; + + var o = vec4(0.0); + + let crease = exp(-(dx * dx) / 100.0); + o = over(o, BLACK, crease * 0.08); + + // Backlight flicker around the damage once it is fully broken. + let wobble = max(0.06 * sin(t * 13.0) * sin(t * 2.9 + 1.0), 0.0); + let near = exp(-length(vec2(dx * 0.4, q.y - y0)) / 160.0); + o = over(o, BLACK, s4 * (wobble + 0.2 * g) * near); + + // Stage 3: black ink bleeding out of the failure point, a dead column down + // the whole hinge, and a dithered fringe of dead pixels around the blot. + if (s3 > 0.0) { + let sd = inkDist(vec2(qx, q.y), hx, y0, size, s3, s4); + let core = 1.0 - smoothstep(-1.0, 1.0, sd); + let strip = (1.5 + 4.5 * s4) * s3 * (0.6 + 0.8 * noise(vec2(q.y * 0.04, 1.0))); + let column = 1.0 - smoothstep(-0.5, 0.8, abs(qx - hx) - strip); + let fringe = (1.0 - smoothstep(0.0, 12.0, sd)) * step(0.0, sd); + let cell = floor(q * dpr / 2.0); + let dead = step(1.0 - fringe * 0.75, hash21(cell)); + o = over(o, vec3(0.2, 0.02, 0.28), fringe * 0.4 * s3); + o = over(o, BLACK, max(max(core, column), dead)); + } + + // Stage 2: stuck pixel columns shooting up and down from the failure point, + // and a white row out to the right edge. Stage 4 spreads them sideways. + if (s2 > 0.0) { + let col = floor(qx * dpr) - floor(hx * dpr); + let id = floor(col / 2.0); + let spread = (24.0 + size.x * 0.17 * s4) * dpr; + let lit = min(s2 * 2.5, 1.0); + let reach = size.y * smoothstep(0.0, 0.9, s2); + var rgb = BLACK; + var a = 0.0; + if (col >= 3.0 && col < 9.0) { + rgb = WHITE; + a = 1.0; + } else if (col >= -7.0 && col < -3.0) { + rgb = GREEN; + a = 0.95; + } else if (col >= 14.0 && col < 19.0) { + rgb = MAGENTA; + a = 0.95; + } else if (col >= -20.0 && col < -16.0) { + rgb = CYAN; + a = 0.9; + } else if (abs(col) < spread) { + let shown = hash11(id + 9.1) < s2 * 0.6 + s4 * 0.9; + if (hash11(id * 1.37 + 5.0) > 0.88 && shown) { + rgb = palette(hash11(id + 2.3)); + a = 0.85; + let partial = hash11(id + 4.2); + if (partial > 0.55) { + let mid = size.y * hash11(id + 6.6); + a = a * step(abs(q.y - mid), size.y * 0.45 * partial); + } + } + } + let flicker = 0.88 + 0.12 * sin(t * 31.0 + id * 1.7); + o = over(o, rgb, a * lit * flicker * step(vy, reach) * (1.0 - 0.4 * g)); + + let row = floor(q.y * dpr) - floor(y0 * dpr); + let rowReach = hx + (size.x - hx) * smoothstep(0.0, 0.8, s2); + if (row >= -3.0 && row < 3.0 && qx > hx && qx < rowReach) { + o = over(o, WHITE, lit); + } + if (row >= 6.0 && row < 9.0 && qx > hx && qx < rowReach) { + o = over(o, MAGENTA, lit * 0.9); + } + } + + // Stage 1: the crease cracks open from the failure point to both edges, + // with a few short fractures branching out where it started. + if (s1 > 0.0) { + if (vy < size.y * smoothstep(0.0, 1.0, s1)) { + let d = q.x - crackX(q.y, hx); + let shadow = 1.0 - smoothstep(1.0, 5.0, abs(d)); + let core = 1.0 - smoothstep(0.6, 1.4, abs(d)); + let glint = 1.0 - smoothstep(0.0, 0.7, abs(d - 1.7)); + o = over(o, BLACK, shadow * 0.35); + o = over(o, BLACK, core * 0.95); + o = over(o, vec3(0.92, 0.93, 0.95), glint * 0.6); + } + let c = vec2(crackX(y0, hx), y0); + var bd = 1000.0; + for (var i = 0; i < 6; i = i + 1) { + let fi = f32(i); + let ang = fi * 1.047 + 0.4 + (hash11(fi) - 0.5) * 0.7; + let dir = vec2(cos(ang), sin(ang)); + let len = (14.0 + 30.0 * hash11(fi + 3.0)) * s1; + let mid = c + dir * len * 0.5 + vec2(-dir.y, dir.x) * (hash11(fi + 7.0) - 0.5) * 6.0; + bd = min(bd, min(segDist(q, c, mid), segDist(q, mid, c + dir * len))); + } + o = over(o, BLACK, (1.0 - smoothstep(0.4, 1.1, bd)) * 0.9); + o = over(o, WHITE, (1.0 - smoothstep(0.0, 0.6, abs(bd - 1.3))) * 0.3); + o = over(o, BLACK, 1.0 - smoothstep(3.0, 5.0, length(q - c))); + } + + // Glitch bursts: a few torn rows of coloured garbage across the damage. + if (g > 0.0 && damage > 0.0) { + let pick = hash21(vec2(band, slot + 11.0)); + if (pick > 0.8 && abs(dx) < size.x * (0.08 + 0.3 * s4)) { + let block = hash21(vec2(floor(q.x * dpr / 6.0), band)); + let on = step(0.5, hash21(vec2(floor(q.x * dpr / 3.0), band + slot))); + o = over(o, palette(block), 0.6 * g * on); + } + } + + return o; +} +`; diff --git a/example/src/components/cracks/LockScreen.tsx b/example/src/components/cracks/LockScreen.tsx new file mode 100644 index 0000000..b253095 --- /dev/null +++ b/example/src/components/cracks/LockScreen.tsx @@ -0,0 +1,280 @@ +import { type ComponentRef, type Ref, useEffect, useState } from 'react'; +import { + Image, + type ImageSourcePropType, + StyleSheet, + Text, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const GLYPHS = { + lock: require('../../../assets/cracks/lock-fill.png'), + flashlight: require('../../../assets/cracks/flashlight-off-fill.png'), + camera: require('../../../assets/cracks/camera-fill.png'), + message: require('../../../assets/cracks/message-fill.png'), +}; + +const DAYS = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +]; +const MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +function pad(n: number) { + return n < 10 ? `0${n}` : `${n}`; +} + +const TICK_MS = 5_000; + +/** Ticks only when `live`: a snapshot of the screen cannot follow the time. */ +function useClock(live: boolean) { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + if (!live) { + return; + } + const id = setInterval(() => setNow(new Date()), TICK_MS); + return () => clearInterval(id); + }, [live]); + return { + time: `${pad(now.getHours())}:${pad(now.getMinutes())}`, + date: `${DAYS[now.getDay()]} ${now.getDate()} ${MONTHS[now.getMonth()]}`, + }; +} + +function Glyph({ + source, + height, + aspect, + color = '#fff', +}: { + source: ImageSourcePropType; + height: number; + aspect: number; + color?: string; +}) { + return ( + + ); +} + +type LockScreenProps = { + wallpaper: ImageSourcePropType; + ref?: Ref>; + /** Side by side layout of the unfolded iPhone Duo inner panel. */ + landscape?: boolean; + /** Show a Messages notification above the bottom buttons. */ + notification?: { title: string; body: string }; + clockColor?: string; + /** Black wash over the wallpaper, 0..1. */ + dim?: number; + /** Keep the clock ticking (only when the screen is not snapshotted). */ + liveClock?: boolean; + onLayout?: () => void; +}; + +/** + * A static clone of the iOS 26 lock screen (lock glyph, date, + * clock, flashlight and camera buttons, home indicator) over a wallpaper + * photo. Rendered as plain views so it can be snapshotted into a texture. + */ +export default function LockScreen({ + wallpaper, + ref, + landscape = false, + notification, + clockColor = 'rgba(255,255,255,0.94)', + dim = 0.12, + liveClock = false, + onLayout, +}: LockScreenProps) { + const insets = useSafeAreaInsets(); + const { time, date } = useClock(liveClock); + const top = Math.max(insets.top, 20); + const clockSize = landscape ? 104 : 96; + + return ( + + + + + + + {date} + + {time} + + + + {notification ? ( + + + + + + + {notification.title} + now + + + {notification.body} + + + + ) : null} + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + ...StyleSheet.absoluteFill, + backgroundColor: '#000', + overflow: 'hidden', + }, + fill: { + ...StyleSheet.absoluteFill, + width: '100%', + height: '100%', + }, + shade: { + ...StyleSheet.absoluteFill, + backgroundColor: '#000', + }, + head: { + alignItems: 'center', + }, + date: { + marginTop: 8, + color: 'rgba(255,255,255,0.92)', + fontSize: 20, + fontWeight: '600', + }, + clock: { + fontWeight: '700', + letterSpacing: -2, + fontVariant: ['tabular-nums'], + }, + notification: { + position: 'absolute', + left: 12, + right: 12, + flexDirection: 'row', + alignItems: 'center', + padding: 13, + gap: 11, + borderRadius: 24, + backgroundColor: 'rgba(40,40,46,0.55)', + }, + appIcon: { + width: 38, + height: 38, + borderRadius: 9, + backgroundColor: '#34C759', + alignItems: 'center', + justifyContent: 'center', + }, + notificationText: { + flex: 1, + }, + notificationRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + notificationTitle: { + color: '#fff', + fontSize: 15, + fontWeight: '600', + }, + notificationTime: { + color: 'rgba(255,255,255,0.6)', + fontSize: 13, + }, + notificationBody: { + color: '#fff', + fontSize: 15, + marginTop: 1, + }, + bottomRow: { + position: 'absolute', + left: 46, + right: 46, + flexDirection: 'row', + justifyContent: 'space-between', + }, + bottomRowWide: { + left: 60, + right: 60, + }, + roundButton: { + width: 50, + height: 50, + borderRadius: 25, + backgroundColor: 'rgba(30,30,34,0.42)', + alignItems: 'center', + justifyContent: 'center', + }, + homeIndicator: { + position: 'absolute', + bottom: 8, + alignSelf: 'center', + width: 140, + height: 5, + borderRadius: 3, + backgroundColor: 'rgba(255,255,255,0.9)', + }, +}); diff --git a/example/src/components/cracks/useCrackSound.ts b/example/src/components/cracks/useCrackSound.ts new file mode 100644 index 0000000..51c43af --- /dev/null +++ b/example/src/components/cracks/useCrackSound.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { AudioContext, type AudioBuffer } from 'react-native-audio-api'; + +const NOISE_SECONDS = 0.25; +const TINKLES = 12; + +type Synth = { ctx: AudioContext; noise: AudioBuffer }; + +function createSynth(): Synth { + const ctx = new AudioContext(); + const length = Math.floor(ctx.sampleRate * NOISE_SECONDS); + const noise = ctx.createBuffer(1, length, ctx.sampleRate); + const data = new Float32Array(length); + for (let i = 0; i < length; i++) { + data[i] = Math.random() * 2 - 1; + } + noise.copyToChannel(data, 0); + return { ctx, noise }; +} + +function between(lo: number, hi: number): number { + return lo + Math.random() * (hi - lo); +} + +function noiseBurst( + { ctx, noise }: Synth, + t: number, + freq: number, + level: number, + decay: number +) { + const src = ctx.createBufferSource(); + src.buffer = noise; + src.playbackRate.value = between(0.8, 1.2); + const filter = ctx.createBiquadFilter(); + filter.type = 'highpass'; + filter.frequency.value = freq; + const gain = ctx.createGain(); + gain.gain.setValueAtTime(level, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + decay); + src.connect(filter); + filter.connect(gain); + gain.connect(ctx.destination); + src.start(t); + src.stop(t + decay + 0.02); +} + +function ping({ ctx }: Synth, t: number, freq: number, level: number) { + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.value = freq; + const gain = ctx.createGain(); + gain.gain.setValueAtTime(level, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.05); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(t); + osc.stop(t + 0.06); +} + +function thud({ ctx }: Synth, t: number, level: number) { + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(between(110, 150), t); + osc.frequency.exponentialRampToValueAtTime(45, t + 0.12); + const gain = ctx.createGain(); + gain.gain.setValueAtTime(level, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.14); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(t); + osc.stop(t + 0.16); +} + +/** + * A synthesized cover glass breaking: a dull thud of the hit, a sharp + * high-passed snap as the crack runs, then a scatter of fading glassy ticks + * and pings for the splinters settling. + */ +export function useCrackSound() { + const synthRef = useRef(null); + + useEffect(() => { + return () => { + synthRef.current?.ctx.close(); + synthRef.current = null; + }; + }, []); + + return useCallback(() => { + if (synthRef.current === null) { + synthRef.current = createSynth(); + } + const synth = synthRef.current; + const t = synth.ctx.currentTime + 0.005; + thud(synth, t, 0.7); + noiseBurst(synth, t, between(1800, 2600), 1.1, 0.07); + noiseBurst(synth, t + 0.02, 4500, 0.5, 0.18); + for (let i = 0; i < TINKLES; i++) { + const at = t + 0.04 + Math.pow(Math.random(), 1.6) * 0.45; + const fade = 1 - (at - t) / 0.55; + if (Math.random() < 0.5) { + ping(synth, at, between(3800, 7500), 0.12 * fade); + } else { + noiseBurst(synth, at, between(5000, 8000), 0.25 * fade, 0.015); + } + } + }, []); +} diff --git a/example/src/components/cracks/useCrackedGlass.ts b/example/src/components/cracks/useCrackedGlass.ts new file mode 100644 index 0000000..97baca6 --- /dev/null +++ b/example/src/components/cracks/useCrackedGlass.ts @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { Vibration } from 'react-native'; +import { Accelerometer } from 'expo-sensors'; +import { useParamsSynchronizable } from 'react-native-effects'; +import { CRACK_CHANNEL_LENGTH, MAX_IMPACTS } from './CrackedGlass'; +import { useCrackSound } from './useCrackSound'; + +/** Shake, cracks, debris and the spreading ink blot all settle by then. */ +const SETTLE_S = 2.5; +const DEAD_PIXEL_CHANCE = 0.45; +const TILT_SMOOTHING = 0.15; + +type Impact = { + x: number; + y: number; + seed: number; + strength: number; + deadPixels: boolean; + start: number; +}; + +type Tilt = { x: number; y: number }; +type ImpactOverrides = Partial< + Pick +>; + +function ageOf(impact: Impact, now: number, slowdown: number) { + return Math.min(SETTLE_S, (now - impact.start) / 1000 / slowdown); +} + +/** + * Impacts and tilt for {@link CrackedGlass}. `hit(x, y)` cracks the glass at + * a point (logical px) with a short buzz and a breaking-glass sound; past + * {@link MAX_IMPACTS} the oldest impact is replaced. Tilt moves the light the + * crack edges catch: the accelerometer on a device, or `__crackTilt(x, y)` in + * `__DEV__` (0.5, 0.5 is flat; `null` hands control back to the sensor). + * `__crackHit(x, y, { seed, strength, deadPixels })` repeats a given impact + * and `__crackSlow(factor)` slows every impact animation by that factor. + */ +export function useCrackedGlass() { + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable(new Array(CRACK_CHANNEL_LENGTH).fill(0)); + const impacts = useRef([]); + const tilt = useRef({ x: 0.5, y: 0.5 }); + const tiltOverride = useRef(null); + const raf = useRef(0); + const slowdown = useRef(1); + const playCrack = useCrackSound(); + + const write = useCallback( + (now: number) => { + const t = tiltOverride.current ?? tilt.current; + const data = [impacts.current.length, t.x, t.y, 0]; + for (const impact of impacts.current) { + data.push(impact.x, impact.y, ageOf(impact, now, slowdown.current)); + data.push(impact.seed, impact.strength, impact.deadPixels ? 1 : 0); + data.push(0, 0); + } + while (data.length < CRACK_CHANNEL_LENGTH) { + data.push(0); + } + setParamsSynchronizable(...data); + }, + [setParamsSynchronizable] + ); + + const animate = useCallback(() => { + const step = (now: number) => { + write(now); + const growing = impacts.current.some( + (i) => ageOf(i, now, slowdown.current) < SETTLE_S + ); + raf.current = growing ? requestAnimationFrame(step) : 0; + }; + if (!raf.current) { + raf.current = requestAnimationFrame(step); + } + }, [write]); + + const hit = useCallback( + (x: number, y: number, overrides?: ImpactOverrides) => { + const impact: Impact = { + x, + y, + seed: Math.random(), + strength: 0.85 + Math.random() * 0.3, + deadPixels: Math.random() < DEAD_PIXEL_CHANCE, + start: performance.now(), + ...overrides, + }; + impacts.current = [...impacts.current, impact].slice(-MAX_IMPACTS); + Vibration.vibrate(40); + playCrack(); + animate(); + }, + [animate, playCrack] + ); + + const reset = useCallback(() => { + impacts.current = []; + write(performance.now()); + }, [write]); + + useEffect(() => { + let sub: { remove: () => void } | null = null; + Accelerometer.isAvailableAsync() + .then((ok) => { + if (!ok) { + return; + } + Accelerometer.setUpdateInterval(33); + sub = Accelerometer.addListener(({ x, y }) => { + const t = tilt.current; + t.x += (0.5 + x * 0.5 - t.x) * TILT_SMOOTHING; + t.y += (0.5 + (y + 0.55) * 0.5 - t.y) * TILT_SMOOTHING; + if (!raf.current && !tiltOverride.current) { + write(performance.now()); + } + }); + }) + .catch(() => {}); + return () => sub?.remove(); + }, [write]); + + useEffect(() => { + if (!__DEV__) { + return; + } + const g = globalThis as { + __crackTilt?: (x: number | null, y?: number) => void; + __crackHit?: (x: number, y: number, overrides?: ImpactOverrides) => void; + __crackSlow?: (factor: number) => void; + }; + g.__crackTilt = (x, y = 0.5) => { + tiltOverride.current = x === null ? null : { x, y }; + write(performance.now()); + }; + g.__crackHit = hit; + g.__crackSlow = (factor) => { + slowdown.current = Math.max(1, factor); + }; + return () => { + delete g.__crackSlow; + delete g.__crackTilt; + delete g.__crackHit; + }; + }, [hit, write]); + + useEffect(() => () => cancelAnimationFrame(raf.current), []); + + return { paramsSynchronizable, hit, reset }; +} diff --git a/example/src/components/cracks/useHingeFailure.ts b/example/src/components/cracks/useHingeFailure.ts new file mode 100644 index 0000000..40e6aca --- /dev/null +++ b/example/src/components/cracks/useHingeFailure.ts @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { Vibration } from 'react-native'; +import { useParamsSynchronizable } from 'react-native-effects'; +import { FAILURE_STAGES } from './HingeFailure'; + +/** How long each stage takes to set in: crack, colour lines, ink, spread. */ +const STAGE_MS = [650, 700, 1300, 1700]; +/** The panel looks healthy for a beat before it starts to go. */ +const START_DELAY_MS = 900; +const GLITCH_MS = 320; + +type State = { + target: number; + damage: number; + damageOverride: number | null; + glitch: number; + last: number; +}; + +function approach(value: number, target: number, step: number) { + return value < target + ? Math.min(target, value + step) + : Math.max(target, value - step); +} + +/** + * Drives {@link HingeFailure}. `play()` shows a healthy panel for a moment and + * then lets the hinge fail through every stage on its own, `advance()` skips + * to the next stage (or replays once fully broken), `reset()` puts a fresh + * panel in. Every new stage lands with a short glitch. In `__DEV__`, + * `__crackgateDamage(0..4)` pins the damage and `null` hands it back. + */ +export function useHingeFailure() { + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable([0, 0, 0, 0]); + const state = useRef({ + target: 0, + damage: 0, + damageOverride: null, + glitch: 0, + last: 0, + }); + const raf = useRef(0); + const startTimer = useRef | undefined>( + undefined + ); + + const publish = useCallback(() => { + const s = state.current; + setParamsSynchronizable(s.damageOverride ?? s.damage, s.glitch, 0, 0); + }, [setParamsSynchronizable]); + + const step = useCallback( + (now: number) => { + const s = state.current; + const dt = Math.min(64, now - (s.last || now)); + s.last = now; + const before = Math.floor(s.damage); + const stageMs = STAGE_MS[Math.min(before, 3)] ?? 1000; + s.damage = approach(s.damage, s.target, dt / stageMs); + if (Math.floor(s.damage) > before) { + s.glitch = 1; + Vibration.vibrate(25); + } + s.glitch = Math.max(0, s.glitch - dt / GLITCH_MS); + publish(); + if (s.damage === s.target && s.glitch === 0) { + raf.current = 0; + s.last = 0; + return; + } + raf.current = requestAnimationFrame(step); + }, + [publish] + ); + + const kick = useCallback(() => { + if (!raf.current) { + raf.current = requestAnimationFrame(step); + } + }, [step]); + + const reset = useCallback(() => { + clearTimeout(startTimer.current); + const s = state.current; + s.target = 0; + s.damage = 0; + s.glitch = 0; + publish(); + }, [publish]); + + const play = useCallback(() => { + reset(); + startTimer.current = setTimeout(() => { + state.current.target = FAILURE_STAGES; + kick(); + }, START_DELAY_MS); + }, [kick, reset]); + + const advance = useCallback(() => { + const s = state.current; + if (s.damage >= FAILURE_STAGES) { + play(); + return; + } + clearTimeout(startTimer.current); + s.damage = Math.floor(s.damage) + 1; + s.target = FAILURE_STAGES; + s.glitch = 1; + Vibration.vibrate(25); + kick(); + }, [kick, play]); + + useEffect(() => { + if (!__DEV__) { + return; + } + const g = globalThis as { + __crackgateDamage?: (value: number | null) => void; + }; + g.__crackgateDamage = (value) => { + state.current.damageOverride = + value === null ? null : Math.max(0, Math.min(FAILURE_STAGES, value)); + publish(); + }; + return () => { + delete g.__crackgateDamage; + }; + }, [publish]); + + useEffect( + () => () => { + cancelAnimationFrame(raf.current); + clearTimeout(startTimer.current); + }, + [] + ); + + return { paramsSynchronizable, play, advance, reset }; +} diff --git a/example/src/components/cracks/useSnapshotTexture.ts b/example/src/components/cracks/useSnapshotTexture.ts new file mode 100644 index 0000000..5764af8 --- /dev/null +++ b/example/src/components/cracks/useSnapshotTexture.ts @@ -0,0 +1,84 @@ +import { + type ComponentRef, + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import type { View } from 'react-native'; +import { captureRef, releaseCapture } from 'react-native-view-shot'; + +/** Wallpaper decode and layout settle before the snapshot is taken. */ +const SETTLE_MS = 450; + +async function snapshot(view: RefObject | null>) { + const raw = await captureRef(view, { + format: 'png', + quality: 1, + result: 'tmpfile', + }); + return raw.startsWith('file://') ? raw : `file://${raw}`; +} + +/** + * Snapshots a view into a PNG for a ShaderView `texture`. Call `schedule()` + * once the view has laid out, or `invalidate()` when its size changes so a + * stretched stale snapshot is never shown. The latest snapshot wins and the + * previous file is released. + */ +export function useSnapshotTexture( + view: RefObject | null> +) { + const [uri, setUri] = useState(null); + const timer = useRef | null>(null); + const generation = useRef(0); + + const capture = useCallback(async () => { + const gen = ++generation.current; + try { + const next = await snapshot(view); + if (gen !== generation.current) { + releaseCapture(next); + return; + } + setUri((prev) => { + if (prev) { + releaseCapture(prev); + } + return next; + }); + } catch (e) { + console.warn('[cracks] snapshot failed:', e); + } + }, [view]); + + const schedule = useCallback(() => { + if (timer.current) { + clearTimeout(timer.current); + } + timer.current = setTimeout(capture, SETTLE_MS); + }, [capture]); + + const invalidate = useCallback(() => { + generation.current++; + setUri((prev) => { + if (prev) { + releaseCapture(prev); + } + return null; + }); + schedule(); + }, [schedule]); + + useEffect( + () => () => { + if (timer.current) { + clearTimeout(timer.current); + } + }, + [] + ); + + return { uri, schedule, invalidate }; +} diff --git a/example/src/components/crt/CrtTube.tsx b/example/src/components/crt/CrtTube.tsx new file mode 100644 index 0000000..a1a3f83 --- /dev/null +++ b/example/src/components/crt/CrtTube.tsx @@ -0,0 +1,179 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +type CrtTubeProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'transparent' +> & { + /** `u.live = (powerOff 0|1, seconds since the last toggle, 0, 0)`. */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A snapshot of a screen shown on an old CRT tube. At rest it matches the + * snapshot exactly; barrel curvature, scanlines, an RGB phosphor mask and a + * rolling band only ramp in during a transition. Power off squashes the picture into a white-hot horizontal line, pulls the line + * into a dot and lets the dot's phosphor fade; power on runs it back with an + * overshoot and a decaying degauss wobble. + */ +export default function CrtTube({ + paramsSynchronizable, + ...rest +}: CrtTubeProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +// Barrel curvature at rest and at full squash: at rest the tube shows the +// app untouched, the glass bulges as the picture collapses. +const CURVE_REST: f32 = 0.0; +const CURVE_SQUASH: f32 = 0.16; +const PHOSPHOR: vec3 = vec3(0.86, 0.93, 1.0); + +// Picture scale (x, y), the collapsed dot's brightness and the degauss +// wobble, all as functions of the seconds since the last toggle. +struct Beam { + scale: vec2, + spot: f32, + wobble: f32, +}; + +fn sdBox(p: vec2, b: vec2) -> f32 { + let d = abs(p) - b; + return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0); +} + +fn easeIn(x: f32) -> f32 { + let c = clamp(x, 0.0, 1.0); + return c * c * c; +} + +fn easeOut(x: f32) -> f32 { + let c = 1.0 - clamp(x, 0.0, 1.0); + return 1.0 - c * c * c; +} + +fn powerOff(e: f32) -> Beam { + let squash = easeIn(e / 0.24); + let pinch = smoothstep(0.25, 0.46, e); + var b: Beam; + b.scale = vec2((1.0 + 0.04 * squash) * (1.0 - pinch), 1.0 - squash); + let fade = exp(-max(e - 0.46, 0.0) / 0.2) * 0.8 + exp(-max(e - 0.46, 0.0) / 0.6) * 0.2; + b.spot = smoothstep(0.38, 0.46, e) * fade; + b.wobble = 0.0; + return b; +} + +fn powerOn(e: f32) -> Beam { + let t = max(e - 0.2, 0.0); + var b: Beam; + b.scale = vec2( + easeOut((e - 0.07) / 0.15), + select(0.0, 1.0 - exp(-t * 12.0) * cos(t * 13.0), e > 0.2) + ); + b.spot = smoothstep(0.0, 0.05, e) * (1.0 - smoothstep(0.08, 0.2, e)); + b.wobble = smoothstep(0.18, 0.28, e) * exp(-max(e - 0.26, 0.0) * 3.2) * (1.0 - smoothstep(1.5, 2.1, e)); + return b; +} + +fn screen(cuv: vec2, wob: f32) -> vec3 { + let tuv = vec2(cuv.x, 1.0 - cuv.y); + let shift = vec2(wob * 0.006, 0.0); + let g = textureSampleLevel(tex, samp, tuv, 0.0).g; + let r = textureSampleLevel(tex, samp, tuv + shift, 0.0).r; + let bl = textureSampleLevel(tex, samp, tuv - shift, 0.0).b; + return vec3(r, g, bl); +} + +fn phosphorMask(px: f32) -> vec3 { + let k = floor(px % 3.0); + return vec3( + select(0.93, 1.1, k < 0.5), + select(0.93, 1.1, k > 0.5 && k < 1.5), + select(0.93, 1.1, k > 1.5) + ); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let res = u.resolution.xy; + let dpr = u.resolution.w; + let e = u.live.y; + var b: Beam; + if (u.live.x > 0.5) { + b = powerOff(e); + } else { + b = powerOn(e); + } + + let p = (uv - 0.5) * res; + let box = res * 0.5 * b.scale; + let d = sdBox(p, box); + let compress = 1.0 - clamp(b.scale.y, 0.0, 1.0); + let lit = step(0.001, b.scale.x); + + var col = vec3(0.0); + + if (d < 0.0 && b.scale.y > 0.002) { + var local = p / box; + local.x = local.x + b.wobble * 0.035 * sin(local.y * 9.0 + e * 38.0); + var c = local * 0.5; + let curve = mix(CURVE_REST, CURVE_SQUASH, smoothstep(0.0, 0.5, compress + b.wobble)); + c = c * (1.0 + curve * (dot(c, c) - 0.25)); + let cuv = c + 0.5; + let edge = smoothstep(0.0, 0.006, cuv.x) * smoothstep(1.0, 0.994, cuv.x) + * smoothstep(0.0, 0.004, cuv.y) * smoothstep(1.0, 0.996, cuv.y); + + var img = screen(clamp(cuv, vec2(0.0), vec2(1.0)), b.wobble); + let t = u.time.x; + let crt = smoothstep(0.0, 0.4, compress + b.wobble); + let scan = 1.0 - 0.06 * crt * (0.5 - 0.5 * sin(cuv.y * res.y * 2.0944)); + let flick = 1.0 + 0.012 * crt * sin(t * 9.0) * sin(t * 2.3); + let band = 0.035 * crt * exp(-pow((fract(cuv.y - t * 0.11) - 0.5) * 9.0, 2.0)); + let vign = 1.0 - 0.35 * crt * dot(c, c); + let mask = mix(vec3(1.0), phosphorMask(uv.x * res.x), crt); + img = img * mask * scan * flick * vign + band; + img = img * (1.0 + b.wobble * 0.22 * vec3(sin(e * 21.0), sin(e * 21.0 + 2.1), sin(e * 21.0 + 4.2))); + + let boost = 1.0 + 2.2 * compress * compress; + let hot = vec3(1.0) - exp(-img * boost * 1.5); + img = mix(img, hot, smoothstep(0.0, 0.3, compress)); + img = mix(img, PHOSPHOR * 1.4, smoothstep(0.09, 0.005, b.scale.y)); + col = img * mix(1.0, edge, crt); + } + + let glowR = (6.0 + 10.0 * compress) * dpr; + let halo = exp(-max(d, 0.0) / glowR) * compress * compress * 0.75; + let core = (1.0 - smoothstep(0.0, 1.4 * dpr, d)) * smoothstep(0.6, 1.0, compress); + col = col + PHOSPHOR * (halo + core * 1.3) * lit; + + let rr = length(p) / dpr; + col = col + PHOSPHOR * b.spot * (1.7 * exp(-rr * rr / 14.0) + 0.45 * exp(-rr / 12.0)); + + return vec4(min(col, vec3(1.0)), 1.0); +} +`; diff --git a/example/src/components/crt/tubeSound.ts b/example/src/components/crt/tubeSound.ts new file mode 100644 index 0000000..0e2f151 --- /dev/null +++ b/example/src/components/crt/tubeSound.ts @@ -0,0 +1,96 @@ +import { AudioContext } from 'react-native-audio-api'; + +let ctx: AudioContext | null = null; + +function context() { + ctx = ctx ?? new AudioContext(); + return ctx; +} + +function envelope(ac: AudioContext, at: number, peak: number, length: number) { + const env = ac.createGain(); + env.gain.setValueAtTime(0, at); + env.gain.linearRampToValueAtTime(peak, at + 0.006); + env.gain.exponentialRampToValueAtTime(0.0005, at + length); + env.connect(ac.destination); + return env; +} + +function thump(ac: AudioContext, at: number, from: number, to: number) { + const osc = ac.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(from, at); + osc.frequency.exponentialRampToValueAtTime(to, at + 0.3); + osc.connect(envelope(ac, at, 0.5, 0.35)); + osc.start(at); + osc.stop(at + 0.4); +} + +function crackle(ac: AudioContext, at: number, length: number, peak: number) { + const buffer = ac.createBuffer( + 1, + Math.ceil(ac.sampleRate * length), + ac.sampleRate + ); + const data = buffer.getChannelData(0); + for (let i = 0; i < data.length; i++) { + data[i] = Math.random() < 0.04 ? Math.random() * 2 - 1 : 0; + } + const src = ac.createBufferSource(); + src.buffer = buffer; + const hp = ac.createBiquadFilter(); + hp.type = 'highpass'; + hp.frequency.value = 2500; + src.connect(hp); + hp.connect(envelope(ac, at, peak, length)); + src.start(at); +} + +function whine(ac: AudioContext, at: number, length: number) { + const osc = ac.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(15734, at); + osc.frequency.linearRampToValueAtTime(15400, at + length); + osc.connect(envelope(ac, at, 0.025, length)); + osc.start(at); + osc.stop(at + length + 0.05); +} + +function hum(ac: AudioContext, at: number, length: number) { + const osc = ac.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.value = 60; + const lp = ac.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = 220; + osc.connect(lp); + lp.connect(envelope(ac, at, 0.22, length)); + osc.start(at); + osc.stop(at + length + 0.05); +} + +/** The tube switching off: a relay thunk, static and the flyback whine dying. */ +export function playPowerOff() { + try { + const ac = context(); + const t0 = ac.currentTime + 0.01; + thump(ac, t0, 90, 38); + crackle(ac, t0, 0.18, 0.35); + whine(ac, t0, 0.7); + } catch { + // Sound is optional; the picture still collapses. + } +} + +/** The tube warming up: a degauss thump and a fading 60 Hz buzz. */ +export function playPowerOn() { + try { + const ac = context(); + const t0 = ac.currentTime + 0.01; + thump(ac, t0 + 0.18, 55, 32); + hum(ac, t0 + 0.18, 0.9); + crackle(ac, t0, 0.3, 0.25); + } catch { + // Sound is optional; the picture still powers on. + } +} diff --git a/example/src/components/ferrofluid/Ferrofluid.tsx b/example/src/components/ferrofluid/Ferrofluid.tsx new file mode 100644 index 0000000..7ec7c44 --- /dev/null +++ b/example/src/components/ferrofluid/Ferrofluid.tsx @@ -0,0 +1,272 @@ +import type { ImageSourcePropType, ViewProps } from 'react-native'; +import { ShaderView, type ParamsSynchronizable } from 'react-native-effects'; + +type Props = ViewProps & { + /** The surface the fluid sits on, shot from straight above. */ + surface: ImageSourcePropType; + /** + * `[scaleX, scaleY]` mapping screen uv onto the photo so it covers the + * view without stretching (the excess is cropped evenly). + */ + coverScale: [number, number]; + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A puddle of ferrofluid on a photographed surface, seen from above. + * + * The fluid is a height field: a thin lens with a rounded meniscus, plus the + * Rosensweig spike field (a hex lattice of cones) where the magnet's field is + * strong. The spikes rise with the field, crowd closer as it grows and lean + * toward the magnet. It is shaded as a black mirror: almost no diffuse, a + * strong Fresnel term, a softbox overhead and the surrounding surface + * reflected in the flanks of every spike. + */ +export default function Ferrofluid({ + surface, + coverScale, + paramsSynchronizable, + ...viewProps +}: Props) { + return ( + + ); +} + +const FERRO_SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, + liveData: array, 96>, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +const PUDDLE_R = 0.15; +const LENS_H = 0.016; +const SPIKE_H = 0.085; + +fn hash21(p: vec2) -> f32 { + var p3 = fract(vec3(p.xyx) * 0.1031); + p3 = p3 + dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +fn photo(c: vec2) -> vec3 { + let suv = vec2(c.x / u.resolution.z + 0.5, 0.5 - c.y); + let iuv = (suv - 0.5) * u.params0.xy + 0.5; + return textureSampleLevel(tex, samp, iuv, 0.0).rgb; +} + +fn modv(a: vec2, b: vec2) -> vec2 { + return a - b * floor(a / b); +} + +// Offset from p to the nearest hex lattice point, lattice spacing 1. +fn hexOffset(p: vec2) -> vec2 { + let r = vec2(1.0, 1.7320508); + let h = r * 0.5; + let a = modv(p, r) - h; + let b = modv(p - h, r) - h; + return select(b, a, dot(a, a) < dot(b, b)); +} + +struct Fluid { + h: f32, + sd: f32, +}; + +fn puddleSd(p: vec2, center: vec2, dir: vec2, s: f32, phase: f32) -> f32 { + let d = p - center; + let along = dot(d, dir); + let across = dot(d, vec2(-dir.y, dir.x)); + let stretch = 1.0 + select(0.12, 0.55, along > 0.0) * s; + let squeeze = 1.0 - 0.12 * s; + let ang = atan2(d.y, d.x); + let wob = 1.0 + 0.025 * sin(ang * 3.0 + phase * 1.7) + 0.018 * sin(ang * 5.0 - phase * 2.3); + let q = vec2(along / stretch, across / squeeze); + return length(q) - PUDDLE_R * (1.0 - 0.1 * s) * wob; +} + +fn spikeFocus(center: vec2, magnet: vec2) -> vec2 { + let toMag = magnet - center; + let len = length(toMag); + return center + toMag * min(1.0, 0.08 / max(len, 1e-4)); +} + +fn spikeField(p: vec2, focus: vec2, s: f32, sd: f32, phase: f32) -> f32 { + let spacing = 0.046 - 0.01 * s; + let cell = hexOffset(p / spacing); + let id = round((p / spacing - cell) * 2.0); + // Each spike stands a little off the lattice point, and is a concave cone + // that meets the surface with zero slope, so neighbours never leave a seam. + let jit = vec2(hash21(id), hash21(id + 7.13)) - 0.5; + let local = cell - jit * 0.16; + let tip = p - local * spacing; + // Tallest right over the magnet, dying away across the puddle so the far + // side stays a smooth pool; nothing grows in the thin fluid at the rim. + let spread = 0.0025 + 0.004 * s; + let fall = exp(-dot(tip - focus, tip - focus) / spread); + let rim = smoothstep(0.0, 0.05, -sd); + let wobble = 1.0 + 0.07 * sin(phase * 5.0 + hash21(id + 3.7) * 6.2832); + let size = 0.85 + 0.3 * hash21(id + 1.9); + let env = s * rim * fall * wobble * size; + let r = sqrt(dot(local, local) + 0.005) / 0.42; + let t = clamp(1.0 - r, 0.0, 1.0); + return SPIKE_H * env * t * t; +} + +fn fluidAt(p: vec2) -> Fluid { + let center = u.live.xy; + let s = u.live.z; + let phase = u.live.w; + let magnet = u.liveData[0].xy; + let toMag = magnet - center; + let dist = length(toMag); + let dir = select(vec2(0.0, 1.0), toMag / max(dist, 1e-4), dist > 1e-3); + let lean = dir * min(dist, 0.25) * s; + let sd = puddleSd(p, center, dir, min(dist * 4.0, 1.0) * s, phase); + // A rounded rim plus a broad, shallow crown, so reflections curve. + let fromCenter = p - center; + let crown = clamp(1.0 - dot(fromCenter, fromCenter) / (PUDDLE_R * PUDDLE_R), 0.0, 1.0); + let lens = LENS_H * (0.6 * sqrt(clamp(-sd / 0.03, 0.0, 1.0)) + 0.4 * crown); + // Shear the lattice toward the magnet by the spike's own height, so each + // cone leans over instead of the whole grid sliding. + let focus = spikeFocus(center, magnet); + let h0 = spikeField(p, focus, s, sd, phase); + let q = p - lean * (h0 / SPIKE_H) * 0.3; + let h1 = spikeField(q, focus, s, sd, phase); + // The fluid heaps up over the magnet, so the spikes stand on a mound. + let mound = 0.02 * s * exp(-dot(p - focus, p - focus) / (0.01 + 0.012 * s)) * smoothstep(0.0, 0.05, -sd); + var f: Fluid; + f.h = lens + mound + h1; + f.sd = sd; + return f; +} + +fn env(r: vec3, c: vec2) -> vec3 { + // A dim room: a softbox overhead, darker ceiling, and the counter itself + // for rays that bounce downward off steep flanks. + // The ceiling is close: where a ray starts shifts what it sees. + let sky = r.xy / max(r.z, 0.05) + c * 3.0; + let boxUv = (sky - vec2(-0.22, 0.95)) / vec2(0.3, 0.2); + let box = smoothstep(1.0, 0.82, abs(boxUv.x)) * smoothstep(1.0, 0.8, abs(boxUv.y)) * (0.35 + 0.65 * smoothstep(-1.0, 0.9, boxUv.y)) + * (1.0 - 0.85 * smoothstep(0.035, 0.0, abs(boxUv.x)) - 0.85 * smoothstep(0.05, 0.0, abs(boxUv.y))); + let up = smoothstep(-0.05, 0.25, r.z); + let behind = smoothstep(0.9, 0.6, abs(sky.x)) * smoothstep(0.5, 0.3, abs(sky.y + 1.1)); + let ceiling = vec3(0.06, 0.06, 0.065) + vec3(1.0, 0.98, 0.95) * (box * 8.0 + behind * 1.1); + let counter = photo(c + r.xy * 0.05) * 0.38; + // Tall windows to either side: every cone flank facing one catches a + // long vertical streak. + let sill = smoothstep(-0.1, 0.05, r.z) * smoothstep(0.75, 0.45, r.z); + let windows = smoothstep(0.55, 0.72, -r.x) * 2.6 + smoothstep(0.62, 0.8, r.x) * 1.1; + return mix(counter, ceiling, up) + vec3(0.97, 0.98, 1.0) * windows * sill; +} + +fn glints(n: vec3, v: vec3) -> f32 { + // Two low windows, the softbox and a lamp behind the viewer; each lands a + // streak on the flank of every spike that faces it. + let l0 = normalize(vec3(-0.66, 0.66, 0.34)); + let l1 = normalize(vec3(0.85, 0.3, 0.4)); + let l2 = normalize(vec3(-0.12, -0.3, 1.0)); + let s0 = pow(clamp(dot(n, normalize(l0 + v)), 0.0, 1.0), 320.0); + let s1 = pow(clamp(dot(n, normalize(l1 + v)), 0.0, 1.0), 60.0); + let s2 = pow(clamp(dot(n, normalize(l2 + v)), 0.0, 1.0), 160.0); + let l3 = normalize(vec3(0.12, -0.85, 0.5)); + let s3 = pow(clamp(dot(n, normalize(l3 + v)), 0.0, 1.0), 380.0); + return min(s0 * 4.0 + s1 * 0.8 + s2 * 1.6 + s3 * 3.5, 4.0); +} + +// The camera looks down at the counter from the bottom of the screen at an +// angle, so a point raised by z shows up OBLIQUE * z higher on screen. March +// the view ray down through the height field to the first surface it meets. +const OBLIQUE = 0.9; +// Steps scale with the spikes: a flat puddle needs only a few. +const MARCH_STEPS_MIN = 8; +const MARCH_STEPS_MAX = 40; + +fn march(c: vec2, hMax: f32, s: f32) -> vec3 { + let steps = MARCH_STEPS_MIN + i32(f32(MARCH_STEPS_MAX - MARCH_STEPS_MIN) * clamp(s * 1.5, 0.0, 1.0)); + var zPrev = hMax; + for (var i = 1; i <= steps; i = i + 1) { + let z = hMax * (1.0 - f32(i) / f32(steps)); + let g = c - vec2(0.0, OBLIQUE * z); + if (fluidAt(g).h >= z) { + var lo = z; + var hi = zPrev; + for (var j = 0; j < 6; j = j + 1) { + let mid = 0.5 * (lo + hi); + let gm = c - vec2(0.0, OBLIQUE * mid); + if (fluidAt(gm).h >= mid) { + lo = mid; + } else { + hi = mid; + } + } + return vec3(c - vec2(0.0, OBLIQUE * lo), 1.0); + } + zPrev = z; + } + return vec3(c, 0.0); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let c = (uv - 0.5) * vec2(u.resolution.z, 1.0); + let e = 1.0 / u.resolution.y; + let s = max(u.live.z, 0.0); + + let hMax = LENS_H + (SPIKE_H * 1.35 + 0.025) * s + 0.002; + let near = fluidAt(c).sd < 0.01 || fluidAt(c - vec2(0.0, OBLIQUE * hMax)).sd < 0.01; + var hit = vec3(c, 0.0); + if (near) { + hit = march(c, hMax, s); + } + + // Surface: the photo, with a soft contact shadow hugging the puddle and + // the spikes casting a little toward the lower right. + var base = photo(c); + let f0 = fluidAt(c); + let shadowSd = fluidAt(c + vec2(-0.012, 0.016)).sd; + let contact = exp(-max(f0.sd, 0.0) / 0.01); + let dropShadow = exp(-max(shadowSd, 0.0) / 0.03) * 0.25 * clamp(s, 0.0, 1.0); + base = base * (1.0 - 0.45 * contact - dropShadow); + + var col = base; + if (hit.z > 0.5) { + let g = hit.xy; + let h0 = fluidAt(g).h; + let hx = fluidAt(g + vec2(e, 0.0)).h; + let hy = fluidAt(g + vec2(0.0, e)).h; + let n = normalize(vec3(-(hx - h0) / e, -(hy - h0) / e, 1.0)); + let v = normalize(vec3(g.x * 0.25, -0.67, 0.74)); + let r = reflect(-v, n); + let ndv = clamp(dot(n, v), 0.0, 1.0); + let k = 1.0 - ndv; + let fres = 0.09 + 0.91 * k * k * k; + let refl = env(r, g); + let glint = glints(n, v); + col = vec3(0.010, 0.010, 0.013) + refl * fres + vec3(0.95, 0.96, 1.0) * glint * (0.25 + fres); + // Soften the silhouette against the counter where the lens is thin. + let edge = smoothstep(0.0, 0.0025, -fluidAt(g).sd); + col = mix(base, col, edge); + } + col = col / (1.0 + col * 0.15); + + let vig = 1.0 - 0.25 * dot(uv - 0.5, uv - 0.5); + col = col * vig + (hash21(uv * u.resolution.xy) - 0.5) / 255.0; + return vec4(col, 1.0); +}`; diff --git a/example/src/components/flashlight/TorchIsland.tsx b/example/src/components/flashlight/TorchIsland.tsx new file mode 100644 index 0000000..ad976a5 --- /dev/null +++ b/example/src/components/flashlight/TorchIsland.tsx @@ -0,0 +1,292 @@ +import { useEffect } from 'react'; +import { Image, Pressable, StyleSheet } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withDelay, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import { ShaderView, type ParamsSynchronizable } from 'react-native-effects'; + +const GLYPH = require('../../../assets/flashlight/torch-glyph.png'); + +/** The hardware Dynamic Island cut-out, measured on the iPhone 18 Pro Max. */ +export const ISLAND = { width: 95, height: 37, top: 14 }; +/** The expanded torch control, scaled from the iOS 18 screenshots. */ +export const PANEL = { width: 220, height: 318, top: 8, radius: 48 }; +const COMPACT_WIDTH = ISLAND.width + 74; +const APEX_Y = 226; +const GLYPH_SIZE = { width: 26, height: 46 }; + +export type IslandMode = 'idle' | 'compact' | 'expanded'; + +const SPRING = { damping: 17, stiffness: 190, mass: 0.9 }; + +function layoutFor(mode: IslandMode) { + if (mode === 'expanded') { + return { ...PANEL }; + } + return { + width: mode === 'compact' ? COMPACT_WIDTH : ISLAND.width, + height: ISLAND.height, + top: ISLAND.top, + radius: ISLAND.height / 2, + }; +} + +type TorchIslandProps = { + mode: IslandMode; + isOn: boolean; + /** `u.live = (beam 0..1, intensity 0..1, power 0..1, 0)`. */ + paramsSynchronizable: ParamsSynchronizable; + onExpand: () => void; + onToggle: () => void; + /** Drag deltas in pt since the last call. */ + onAdjust: (dx: number, dy: number) => void; +}; + +/** + * The iOS 18 flashlight Dynamic Island: a plain island while the torch is + * off, a compact pill with the torch glyph once it is on, and the expanded + * control (a top-down torch whose beam, solid arc and sun mark follow the + * beam width and intensity). Drag across it to shape the beam, tap it to + * switch the light. + */ +export default function TorchIsland({ + mode, + isOn, + paramsSynchronizable, + onExpand, + onToggle, + onAdjust, +}: TorchIslandProps) { + const start = layoutFor(mode); + const width = useSharedValue(start.width); + const height = useSharedValue(start.height); + const top = useSharedValue(start.top); + const radius = useSharedValue(start.radius); + const panelContent = useSharedValue(mode === 'expanded' ? 1 : 0); + const compactContent = useSharedValue(mode === 'compact' ? 1 : 0); + + useEffect(() => { + const next = layoutFor(mode); + width.value = withSpring(next.width, SPRING); + height.value = withSpring(next.height, SPRING); + top.value = withSpring(next.top, SPRING); + radius.value = withSpring(next.radius, SPRING); + panelContent.value = + mode === 'expanded' + ? withDelay(90, withTiming(1, { duration: 220 })) + : withTiming(0, { duration: 90 }); + compactContent.value = + mode === 'compact' + ? withDelay(160, withTiming(1, { duration: 180 })) + : withTiming(0, { duration: 80 }); + }, [mode, width, height, top, radius, panelContent, compactContent]); + + const shellStyle = useAnimatedStyle(() => ({ + width: width.value, + height: height.value, + top: top.value, + borderRadius: radius.value, + marginLeft: -width.value / 2, + })); + const panelStyle = useAnimatedStyle(() => ({ + opacity: panelContent.value, + transform: [{ scale: 0.85 + 0.15 * panelContent.value }], + })); + const compactStyle = useAnimatedStyle(() => ({ + opacity: compactContent.value, + })); + + const tap = Gesture.Tap() + .runOnJS(true) + .onEnd(() => { + if (mode === 'expanded') { + onToggle(); + } else if (mode === 'compact') { + onExpand(); + } + }); + const pan = Gesture.Pan() + .runOnJS(true) + .minDistance(3) + .onChange((e) => { + if (mode === 'expanded') onAdjust(e.changeX, e.changeY); + }); + const gesture = Gesture.Race(pan, tap); + + return ( + <> + {mode === 'compact' && ( + + )} + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + // Touches on the status bar strip can be claimed by the system, so the + // compact pill also answers taps just below the hardware island. + compactReach: { + position: 'absolute', + top: ISLAND.top + ISLAND.height - 6, + left: '50%', + marginLeft: -COMPACT_WIDTH / 2, + width: COMPACT_WIDTH, + height: 34, + zIndex: 49, + }, + shell: { + position: 'absolute', + left: '50%', + backgroundColor: '#000', + overflow: 'hidden', + zIndex: 50, + }, + panel: { + position: 'absolute', + top: 0, + left: '50%', + marginLeft: -PANEL.width / 2, + width: PANEL.width, + height: PANEL.height, + }, + glyph: { + position: 'absolute', + left: (PANEL.width - GLYPH_SIZE.width) / 2, + top: APEX_Y + 2, + width: GLYPH_SIZE.width, + height: GLYPH_SIZE.height, + }, + glyphOn: { + tintColor: '#e6e4f7', + }, + glyphOff: { + tintColor: '#5d5d66', + }, + compactGlyphWrap: { + position: 'absolute', + left: 15, + top: 0, + bottom: 0, + justifyContent: 'center', + }, + compactGlyph: { + width: 11, + height: 20, + tintColor: '#e6e4f7', + }, +}); + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; + +const MAX_HALF: f32 = 0.74; +const MIN_HALF: f32 = 0.05; +const DOT_GAP: f32 = 5.2; + +fn spectrum(x: f32) -> vec3 { + return 0.5 + 0.5 * cos(6.2831 * (vec3(x) + vec3(0.0, 0.33, 0.67))); +} + +// Point on the arc of radius r at angle a from straight up, panel pt y-down. +fn onArc(apex: vec2, r: f32, a: f32) -> vec2 { + return apex + vec2(sin(a), -cos(a)) * r; +} + +fn sunMark(q: vec2, c: vec2) -> f32 { + let v = q - c; + let d = length(v); + let disc = 1.0 - smoothstep(2.3, 3.0, d); + let a = atan2(v.y, v.x); + let spoke = smoothstep(0.75, 0.95, cos(a * 8.0)); + let ray = spoke * smoothstep(3.9, 4.4, d) * (1.0 - smoothstep(5.6, 6.2, d)); + return max(disc, ray); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let size = u.params0.xy; + let q = vec2(uv.x, 1.0 - uv.y) * size; + let apex = vec2(size.x * 0.5, u.params0.z); + + let beam = clamp(u.live.x, 0.0, 1.0); + let level = clamp(u.live.y, 0.0, 1.0); + let power = clamp(u.live.z, 0.0, 1.0); + + let v = vec2(q.x - apex.x, apex.y - q.y); + let dist = length(v); + let ang = atan2(v.x, v.y); + let a = abs(ang); + let halfA = mix(MIN_HALF, MAX_HALF, beam); + let reach = mix(72.0, 128.0, level) * mix(1.08, 0.94, beam); + let gain = (0.3 + 0.7 * level) * mix(1.5, 1.0, beam); + + let cone = smoothstep(halfA + 0.05, halfA * 0.6, a) * step(0.0, v.y); + let along = pow(smoothstep(reach * 1.04, reach * 0.05, dist), 0.8); + let core = exp(-dist * dist / 180.0); + let body = cone * along * 0.85 + core * 0.95; + let edge = exp(-pow((a - halfA * 0.86) / (0.03 + 0.12 * halfA), 2.0)) + * along * step(0.0, v.y) * 0.22; + let tint = spectrum(a / max(halfA, 0.01) * 0.55 + dist / 150.0); + + var rgb = vec3(0.96, 0.96, 1.0) * body * gain + tint * edge * gain; + var alpha = clamp((body + edge * 0.5) * gain, 0.0, 1.0); + rgb = rgb * power; + alpha = alpha * power; + + let ring = 1.0 - smoothstep(0.55, 1.35, abs(dist - reach)); + let solid = ring * (1.0 - smoothstep(halfA, halfA + 0.02, a)) * step(0.0, v.y); + let s = ang * reach; + let sc = (floor(s / DOT_GAP) + 0.5) * DOT_GAP; + let dotC = onArc(apex, reach, sc / reach); + let dotted = (1.0 - smoothstep(0.55, 1.15, length(q - dotC))) + * step(halfA + 0.03, a) * step(a, MAX_HALF + 0.06); + let sun = sunMark(q, onArc(apex, reach, max(halfA, 0.1) + 0.17)); + let marks = solid * 0.85 + dotted * 0.4 + sun * 0.75; + let markAlpha = marks * mix(0.35, 1.0, power); + + rgb = rgb + vec3(1.0) * markAlpha * (1.0 - alpha); + alpha = alpha + markAlpha * (1.0 - alpha); + return vec4(min(rgb, vec3(1.0)), clamp(alpha, 0.0, 1.0)); +} +`; diff --git a/example/src/components/flashlight/TorchScene.tsx b/example/src/components/flashlight/TorchScene.tsx new file mode 100644 index 0000000..0500415 --- /dev/null +++ b/example/src/components/flashlight/TorchScene.tsx @@ -0,0 +1,110 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +type TorchSceneProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'params' +> & { + /** `u.live = (spotX pt, spotY pt, beam 0 narrow..1 wide, light 0..1)`. */ + paramsSynchronizable: ParamsSynchronizable; + /** Width / height of the photo, for cover-fit sampling. */ + photoAspect: number; +}; + +/** + * A dark photo lit by a phone torch: moonlight-level ambient everywhere, and + * a warm-white spot whose size and brightness follow the beam (a narrow beam + * packs the same light into a smaller, hotter disc). The spot has a hot core, + * a flat flood with a soft rim, faint reflector rings and a hint of colour + * fringing at its edge. + */ +export default function TorchScene({ + paramsSynchronizable, + photoAspect, + ...rest +}: TorchSceneProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +const WARM: vec3 = vec3(1.0, 0.93, 0.8); +const AMBIENT: vec3 = vec3(0.002, 0.0026, 0.0045); + +fn hash21(p: vec2) -> f32 { + var p3 = fract(vec3(p.xyx) * 0.1031); + p3 = p3 + dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +fn coverUv(uv: vec2) -> vec2 { + let screenAspect = u.resolution.z; + let photoAspect = u.params0.x; + var t = vec2(uv.x, 1.0 - uv.y); + if (screenAspect < photoAspect) { + t.x = 0.5 + (t.x - 0.5) * screenAspect / photoAspect; + } else { + t.y = 0.5 + (t.y - 0.5) * photoAspect / screenAspect; + } + return t; +} + +// Radial light profile, r in spot radii. +fn profile(r: f32) -> f32 { + let core = exp(-r * r * 5.0) * 0.9; + let flood = (1.0 - smoothstep(0.1, 1.08, r)) * 0.5; + let rings = 0.035 * exp(-pow((r - 0.9) / 0.045, 2.0)) + + 0.02 * exp(-pow((r - 0.62) / 0.05, 2.0)); + let spill = 0.02 * exp(-r * 1.6); + return core + flood + rings + spill; +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let pt = vec2(uv.x, 1.0 - uv.y) * u.resolution.xy / u.resolution.w; + let photo = textureSampleLevel(tex, samp, coverUv(uv), 0.0).rgb; + let raw = pow(photo, vec3(2.2)); + let albedo = raw / (vec3(1.0) + raw) * 1.7; + + let beam = clamp(u.live.z, 0.0, 1.0); + let light = clamp(u.live.w, 0.0, 1.0); + let radius = mix(58.0, 250.0, beam); + let d = (pt - u.live.xy) / radius; + let r = length(d * vec2(1.0, 1.08)); + let gain = light * mix(2.4, 0.95, beam); + let lit = vec3( + profile(r * 0.985), + profile(r), + profile(r * 1.02) + ) * gain * WARM; + + var col = albedo * (AMBIENT + lit * 1.05); + col = vec3(1.0) - exp(-col * 1.25); + col = pow(col, vec3(1.0 / 2.2)); + col = col + (hash21(uv * u.resolution.xy + fract(u.time.x) * 91.0) - 0.5) / 255.0; + return vec4(col, 1.0); +} +`; diff --git a/example/src/components/lavalamp/LavaLamp.tsx b/example/src/components/lavalamp/LavaLamp.tsx new file mode 100644 index 0000000..b64544e --- /dev/null +++ b/example/src/components/lavalamp/LavaLamp.tsx @@ -0,0 +1,196 @@ +import type { ImageSourcePropType, ViewProps } from 'react-native'; +import { ShaderView, type ParamsSynchronizable } from 'react-native-effects'; + +type Props = ViewProps & { + /** The lamp photo; the shader knows where its glass is. */ + photo: ImageSourcePropType; + /** `[scaleX, scaleY]` mapping screen uv onto the photo, cover-fit. */ + coverScale: [number, number]; + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A photographed lava lamp with live wax. + * + * The cap and the base are the photo, the room around it dimmed to evening + * and warmed by the lamp's own glow. The glass is drawn: a tapered column of + * amber liquid lit by the bulb from below, the simulated wax blobs fused with + * a smooth minimum (thin edges glow orange, thick cores go deep red), a pool + * of wax on the floor, and the vertical reflections of a curved glass wall. + */ +export default function LavaLamp({ + photo, + coverScale, + paramsSynchronizable, + ...viewProps +}: Props) { + return ( + + ); +} + +const LAVA_SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, + liveData: array, 96>, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +// Where the glass is in the photo, pixels. +const IMG = vec2(1280.0, 1795.0); +const GLASS_TOP = 394.0; +const GLASS_FLOOR = 1056.0; +const GLASS_H = 662.0; +const BLOBS = 7; +// The base's top lip, an ellipse seen from slightly above; the glass carries +// on down behind it. +const RIM_X = 647.0; +const RIM_TOP = 1040.0; +const RIM_A = 136.0; +const RIM_B = 45.0; + +fn hash21(p: vec2) -> f32 { + var p3 = fract(vec3(p.xyx) * 0.1031); + p3 = p3 + dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +fn glassHalfWidth(y: f32) -> f32 { + let t = clamp(1.0 - y / GLASS_H, 0.0, 1.0); + let side = 86.0 + 46.0 * (1.0 - (1.0 - t) * (1.0 - t)); + // The bottle rounds into its bottom below this height, behind the base. + let k = max(14.0 - y, 0.0); + return side - 60.0 + sqrt(max(3600.0 - k * k, 0.0)); +} + +// Photo-pixel y of the base's front lip at photo x. +fn rimY(x: f32) -> f32 { + let q = clamp((x - RIM_X) / RIM_A, -1.0, 1.0); + return RIM_TOP + RIM_B * sqrt(1.0 - q * q); +} + +fn glassCenterX(py: f32) -> f32 { + return 632.0 + (py - GLASS_TOP) * (15.0 / GLASS_H); +} + +fn sq(x: f32) -> f32 { + return x * x; +} + +fn smin(a: f32, b: f32, k: f32) -> f32 { + let h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); + return mix(b, a, h) - k * h * (1.0 - h); +} + +// Signed distance to the wax, photo pixels: blobs and the floor pool, fused +// with a smooth minimum so they only neck together when they nearly touch. +// The pool is a dome: wax does not wet glass, so it rounds down to the walls. +fn waxSd(p: vec2, across: f32, phase: f32) -> f32 { + let heave = 1.0 + 0.06 * sin(p.x * 0.045 + phase * 2.1) + 0.04 * sin(p.x * 0.09 - phase * 3.3); + let dome = max(1.0 - across * across, 0.0); + var d = p.y - (-18.0 + 48.0 * dome * heave); + for (var i = 0; i < BLOBS; i = i + 1) { + let b = u.liveData[i]; + let q = p - b.xy; + let stretched = vec2(q.x * (1.0 + b.w * 0.3), q.y / (1.0 + b.w)); + d = smin(d, length(stretched) - b.z, 26.0); + } + return d; +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let suv = vec2(uv.x, 1.0 - uv.y); + let iuv = (suv - 0.5) * u.params0.xy + 0.5; + let px = iuv * IMG; + let pxPerDevice = u.params0.x * IMG.x / u.resolution.x; + let phase = u.live.x; + + let y = GLASS_FLOOR - px.y; + let x = px.x - glassCenterX(px.y); + let hw = glassHalfWidth(y); + let across = x / hw; + let aa = 1.5 * pxPerDevice; + let lip = rimY(px.x); + let aboveRim = smoothstep(-aa, aa, lip - px.y); + let inGlass = smoothstep(hw + aa, hw - aa, abs(x)) + * aboveRim * smoothstep(GLASS_H + aa, GLASS_H - aa, y); + // Inside the base's mouth but outside the rounded bottom of the bottle. + let inMouth = smoothstep(RIM_A + aa, RIM_A - aa, abs(px.x - RIM_X)) + * aboveRim * smoothstep(GLASS_FLOOR - 40.0, GLASS_FLOOR - 20.0, px.y); + + // How much bulb light reaches this height, and how far off-axis. + let h = clamp(y / GLASS_H, 0.0, 1.0); + let heat = exp(-max(y + 30.0, 0.0) / 45.0); + let bulb = (1.2 * exp(-h * 2.6) + 0.48 + 0.35 * heat) * (1.0 - 0.25 * min(across * across, 1.0)); + + // The room: the photo at dusk, warmed by the lamp. + let photo = textureSampleLevel(tex, samp, iuv, 0.0).rgb; + let lampMid = vec2(glassCenterX(GLASS_FLOOR - 240.0), GLASS_FLOOR - 240.0); + let fromLamp = (px - lampMid) / vec2(420.0, 620.0); + let spill = exp(-dot(fromLamp, fromLamp) * 1.6); + var room = photo * vec3(0.24, 0.2, 0.17); + room = room + photo * vec3(1.0, 0.55, 0.22) * spill * 0.75; + room = room + vec3(0.32, 0.12, 0.03) * spill * 0.35; + // The base's lip, lit from inside by the bulb. + let lipX = smoothstep(RIM_A, RIM_A * 0.6, abs(px.x - RIM_X)); + let below = px.y - lip; + room = room + vec3(1.0, 0.5, 0.14) * lipX * exp(-abs(below) / 3.0) * step(0.0, below) * 0.45; + room = room + vec3(0.6, 0.22, 0.05) * lipX * exp(-max(below, 0.0) / 22.0) * step(0.0, below) * 0.18; + let mouth = vec3(0.3, 0.1, 0.02) * (0.5 + 0.5 * heat); + room = mix(room, mouth, inMouth); + + // The glass. The curved wall squeezes what is near its edges. + let bend = across * (0.84 + 0.16 * across * across); + let p = vec2(bend * hw, y); + let sd = waxSd(p, across, phase); + let wax = smoothstep(aa, -aa, sd); + let up = waxSd(p + vec2(0.0, 6.0), across, phase); + let down = waxSd(p - vec2(0.0, 6.0), across, phase); + // How much this bit of wax faces the bulb (its surface normal points down). + let facesBulb = clamp((up - down) / 12.0, 0.0, 1.0); + + // Translucent liquid: the bulb's light, tinted, strongest up the axis. + let axis = exp(-across * across * 2.2); + let liquid = vec3(1.0, 0.66, 0.2) * (0.16 + bulb * (0.32 + 0.22 * axis)); + + // Translucent wax: light scatters through it, so thin edges and necks glow + // bright orange while the thick core deepens to red; the side facing the + // bulb burns hottest. + let depth = clamp(-sd / 38.0, 0.0, 1.0); + let edgeGlow = exp(min(sd, 0.0) / 9.0); + let core = vec3(0.92, 0.16, 0.03); + let thin = vec3(1.0, 0.42, 0.05); + var waxCol = mix(thin, core, depth * (2.0 - depth) * 0.85) * (0.45 + bulb * 0.7); + waxCol = waxCol + vec3(1.0, 0.55, 0.1) * edgeGlow * bulb * 0.35; + waxCol = waxCol + vec3(1.0, 0.78, 0.32) * facesBulb * smoothstep(-22.0, 0.0, sd) * bulb * 0.75; + var glass = mix(liquid, waxCol, wax); + // Light leaking out of the wax tints the liquid around it. + glass = glass + vec3(0.9, 0.3, 0.05) * smoothstep(34.0, 0.0, sd) * (1.0 - wax) * bulb * 0.22; + // Wall: darker, richer toward the rim, with two tall window reflections. + let rim = across * across; + glass = glass * (1.0 - 0.45 * rim * rim * rim); + let streakL = exp(-sq((across + 0.66) / 0.045)) * 0.38 + exp(-sq((across + 0.5) / 0.02)) * 0.12; + let streakR = exp(-sq((across - 0.58) / 0.1)) * 0.12; + glass = glass + vec3(1.0, 0.95, 0.88) * (streakL + streakR) * (0.6 + 0.4 * h); + + var col = mix(room, glass, inGlass); + col = col / (1.0 + col * 0.25); + col = col + (hash21(uv * u.resolution.xy) - 0.5) / 255.0; + return vec4(col, 1.0); +}`; diff --git a/example/src/components/namedrop/ContactPoster.tsx b/example/src/components/namedrop/ContactPoster.tsx new file mode 100644 index 0000000..250ac07 --- /dev/null +++ b/example/src/components/namedrop/ContactPoster.tsx @@ -0,0 +1,228 @@ +import { forwardRef, type ComponentRef } from 'react'; +import { Image, Pressable, StyleSheet, Text, View } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; + +/** + * Photo: "Smiling senior woman with curly hair portrait" by Shixart1985, + * CC BY 2.0, via Wikimedia Commons + * (https://commons.wikimedia.org/wiki/File:Smiling_senior_woman_with_curly_hair_portrait.jpg), + * cropped to the face. + */ +const AVATAR = require('../../../assets/namedrop/avatar.jpg'); + +export type PosterHandle = ComponentRef; + +export type ExchangeChoice = 'share' | 'receive' | null; + +type ContactPosterProps = { + width: number; + height: number; + choice: ExchangeChoice; + onChoose: (choice: 'share' | 'receive') => void; + onAvatarLoad?: () => void; +}; + +const GRADIENT_STEPS = 24; +const TOP = [198, 192, 211]; +const BOTTOM = [160, 151, 172]; + +function gradientColor(i: number) { + const k = i / (GRADIENT_STEPS - 1); + const [r, g, b] = TOP.map((c, j) => Math.round(c + (BOTTOM[j]! - c) * k)); + return `rgb(${r},${g},${b})`; +} + +const GRADIENT = Array.from({ length: GRADIENT_STEPS }, (_, i) => + gradientColor(i) +); + +/** The "Share Your Contact" screen NameDrop shows full screen. */ +const ContactPoster = forwardRef( + function ContactPoster( + { width, height, choice, onChoose, onAvatarLoad }, + ref + ) { + const avatar = Math.round(width * 0.59); + return ( + + + {GRADIENT.map((color) => ( + + ))} + + + Share Your Contact + + + + + + Maren + + maren@holloway.studio + + i + + + + + {choice === null ? ( + + onChoose('receive')} + /> + onChoose('share')} /> + + ) : ( + + {choice === 'share' ? 'Contacts exchanged' : 'Contact received'} + + )} + + + ); + } +); + +export default ContactPoster; + +function PillButton({ + label, + onPress, +}: { + label: string; + onPress: () => void; +}) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + {label} + + ); +} + +const styles = StyleSheet.create({ + band: { + flex: 1, + }, + title: { + position: 'absolute', + left: 0, + right: 0, + textAlign: 'center', + color: '#fff', + fontSize: 19, + fontWeight: '600', + letterSpacing: -0.2, + }, + avatarRing: { + position: 'absolute', + overflow: 'hidden', + backgroundColor: '#f2e6f0', + }, + avatar: { + width: '100%', + height: '100%', + }, + identity: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + }, + name: { + color: '#fff', + fontSize: 64, + lineHeight: 72, + fontWeight: '800', + letterSpacing: -0.8, + }, + emailRow: { + marginTop: 14, + flexDirection: 'row', + alignItems: 'center', + gap: 7, + }, + email: { + color: '#fff', + fontSize: 18, + fontWeight: '500', + }, + info: { + width: 15, + height: 15, + borderRadius: 7.5, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, + infoGlyph: { + color: '#a79fb5', + fontSize: 11, + lineHeight: 13, + fontWeight: '800', + }, + actions: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + }, + buttons: { + flexDirection: 'row', + gap: 12, + paddingHorizontal: 50, + alignSelf: 'stretch', + }, + button: { + flex: 1, + height: 52, + borderRadius: 26, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, + pressed: { + backgroundColor: '#e6e3ec', + }, + buttonText: { + color: '#000', + fontSize: 17, + fontWeight: '500', + }, + status: { + height: 52, + lineHeight: 52, + color: '#fff', + fontSize: 17, + fontWeight: '600', + }, +}); diff --git a/example/src/components/namedrop/NameDropWave.tsx b/example/src/components/namedrop/NameDropWave.tsx new file mode 100644 index 0000000..5b95f7d --- /dev/null +++ b/example/src/components/namedrop/NameDropWave.tsx @@ -0,0 +1,169 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +/** + * The home screen photo next to two pre-blurred copies of itself, so the + * shader can blend any blur strength from three samples. + */ +export const HOME_ATLAS = require('../../../assets/namedrop/home-atlas.jpg'); +/** The Dynamic Island painted into the home screen photo, in uv (y-down). */ +export const ISLAND_UV = { x: 0.5, y: 0.0376, w: 0.312, h: 0.0408 }; + +type NameDropWaveProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'transparent' | 'params' +> & { + /** `u.live.x` = seconds since the phones touched. */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * The home screen as NameDrop bends it: a coloured band of light climbs from + * the bottom edge and pools under the Dynamic Island, blurring what it + * crosses, then a bright liquid wave bursts out of the island and the home + * screen sinks into a dark blur behind the incoming poster. + */ +export default function NameDropWave({ + paramsSynchronizable, + ...rest +}: NameDropWaveProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +fn cell(k: f32, uv: vec2) -> vec3 { + let g = 0.002; + let c = clamp(uv, vec2(g), vec2(1.0 - g)); + return textureSampleLevel(tex, samp, vec2((k + c.x) / 3.0, c.y), 0.0).rgb; +} + +// level 0 is sharp, 1 a light blur, 2 a heavy one. +fn blurred(uv: vec2, level: f32) -> vec3 { + let l = clamp(level, 0.0, 2.0); + let lo = min(floor(l), 1.0); + return mix(cell(lo, uv), cell(lo + 1.0, uv), l - lo); +} + +fn soft(uv: vec2, level: f32, px: vec2) -> vec3 { + let r = px * 7.0 * clamp(level, 0.0, 1.0); + var c = blurred(uv, level) * 2.0; + c += blurred(uv + vec2(r.x, 0.0), level); + c += blurred(uv - vec2(r.x, 0.0), level); + c += blurred(uv + vec2(0.0, r.y), level); + c += blurred(uv - vec2(0.0, r.y), level); + return c / 6.0; +} + +fn glowColor(s: f32) -> vec3 { + let spread = select(0.62, 0.95, s > 0.0); + let envelope = exp(-s * s / (2.0 * spread * spread)); + let tone = mix(vec3(0.42, 0.64, 0.82), vec3(0.92, 0.64, 0.52), smoothstep(-0.9, 1.1, s)); + return tone * envelope; +} + +fn dither(p: vec2) -> f32 { + return (fract(52.9829 * fract(dot(p, vec2(0.06711, 0.00584)))) - 0.5) / 255.0; +} + +fn screen(a: vec3, b: vec3) -> vec3 { + return vec3(1.0) - (vec3(1.0) - a) * (vec3(1.0) - b); +} + +fn pill(p: vec2, size: vec2) -> f32 { + let r = size.y * 0.5; + let q = abs(p) - size * 0.5 + vec2(r); + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let res = u.resolution.xy / u.resolution.w; + let uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5); + let pt = uv * res; + let px = 1.0 / res; + let t = u.live.x; + + let island = u.params0.xy * res; + let islandSize = u.params0.zw * res; + let v = pt - island; + let d = length(v * vec2(0.78, 1.0)); + let dir = v / max(length(v), 0.001); + + let rise = res.y * exp(-3.2 * t); + let dx = pt.x - island.x; + let wobble = 12.0 * sin(pt.x * 0.011 + t * 1.3); + let width = 340.0 + 0.6 * rise; + let s = (pt.y - rise - dx * dx * 0.0006 - wobble) / width; + let settle = smoothstep(360.0, 60.0, rise); + let hold = 1.0 - smoothstep(1.2, 1.32, t); + let begin = smoothstep(0.0, 0.1, t); + let centred = mix(0.35, 1.0, exp(-dx * dx / 36000.0)); + let glow = glowColor(s) * mix(0.11, 0.3, settle) * centred * hold * begin; + + let topHaze = smoothstep(0.0, 0.34, t) * (1.0 - smoothstep(0.36, 0.62, t)) * exp(-pt.y / 95.0); + + let passed = smoothstep(-0.3, 0.1, s) * (1.0 - smoothstep(0.35, 0.8, s)); + let veil = passed * mix(0.4, 1.0, settle) * hold * begin; + let lens = exp(-dot(v, v) / 26000.0) * veil; + var sp = pt - v * lens * 0.18; + + let k = (t - 1.33) / 0.46; + let wave = step(0.0, k) * (1.0 - smoothstep(0.9, 1.25, k)); + let radius = mix(30.0, res.y * 1.3, pow(clamp(k, 0.0, 1.0), 1.2)); + let depth = (radius - d) / 210.0; + let inside = smoothstep(-0.28, 0.16, depth) * wave; + let wash = inside * exp(-max(depth, 0.0) / 2.0); + let rimX = (d - radius) / 95.0; + let rim = exp(-rimX * rimX) * wave; + sp -= dir * rimX * rim * 48.0; + sp -= dir * wash * 26.0; + + let back = smoothstep(1.5, 1.72, t); + let level = veil * 1.7 + rim * 0.5 + wash * 1.5 + back * 2.0; + + var col = soft(sp / res, level, px); + let luma = dot(col, vec3(0.3, 0.59, 0.11)); + col = mix(col, vec3(luma * 0.92, luma * 0.97, luma * 1.08), veil * 0.12); + col *= 1.0 - 0.3 * hold * begin * (1.0 - smoothstep(200.0, 620.0, pt.y)); + col = screen(col, min(glow, vec3(0.95))); + col = screen(col, vec3(0.59, 0.71, 0.94) * topHaze * 0.16); + let coreDistance = length(v * vec2(0.7, 1.0)); + let core = clamp(1.25 * exp(-coreDistance / 125.0), 0.0, 1.0) * smoothstep(0.05, 0.7, t) * hold; + col = screen(col, vec3(0.86, 0.93, 1.0) * core); + + let shade = exp(-pow((d - radius - 56.0) / 62.0, 2.0)) * wave * (1.0 - clamp(k * 2.0, 0.0, 1.0)); + col *= 1.0 - shade * 0.55; + + let waveTint = mix(vec3(0.70, 0.93, 1.0), mix(vec3(0.93, 0.95, 1.0), vec3(0.27, 0.50, 0.86), smoothstep(0.5, 2.0, depth)), smoothstep(0.0, 0.2, depth)); + col = mix(col, waveTint, inside * exp(-max(depth, 0.0) / 3.0) * 0.94); + col = mix(col, vec3(0.08, 0.16, 0.30), back * 0.5); + + let isl = smoothstep(0.0, 1.2, pill(pt - island, islandSize)); + let finished = col * isl + vec3(dither(pt * u.resolution.w)); + return vec4(clamp(finished, vec3(0.0), vec3(1.0)), 1.0); +} +`; diff --git a/example/src/components/namedrop/PosterReveal.tsx b/example/src/components/namedrop/PosterReveal.tsx new file mode 100644 index 0000000..a1c132b --- /dev/null +++ b/example/src/components/namedrop/PosterReveal.tsx @@ -0,0 +1,124 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; +import { ISLAND_UV } from './NameDropWave'; + +type PosterRevealProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'transparent' | 'params' +> & { + /** `u.live.x` = seconds since the phones touched. */ + paramsSynchronizable: ParamsSynchronizable; + /** A full-screen snapshot of the contact poster. */ + snapshotUri: string; +}; + +/** + * The contact poster arriving: a blurred card swells out of the Dynamic + * Island on one critically damped spring, coming into focus as it grows to + * fill the display. + */ +export default function PosterReveal({ + paramsSynchronizable, + snapshotUri, + ...rest +}: PosterRevealProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +const TAPS: i32 = 20; +const START: f32 = 1.57; +const STIFFNESS: f32 = 9.0; +const SPRING_END: f32 = 0.9939; + +fn ease(x: f32) -> f32 { + let c = clamp(x, 0.0, 1.0); + return 1.0 - (1.0 - c) * (1.0 - c) * (1.0 - c); +} + +fn rounded(p: vec2, halfSize: vec2, r: f32) -> f32 { + let q = abs(p) - halfSize + vec2(r); + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; +} + +fn poster(uv: vec2) -> vec3 { + let c = clamp(uv, vec2(0.001), vec2(0.999)); + return textureSampleLevel(tex, samp, c, 0.0).rgb; +} + +fn defocus(uv: vec2, radius: vec2, spin: f32) -> vec3 { + if (radius.x < 0.0005) { + return poster(uv); + } + var c = vec3(0.0); + for (var i = 0; i < TAPS; i++) { + let f = f32(i) + 0.5; + let r = sqrt(f / f32(TAPS)); + let a = f * 2.39996 + spin; + c += poster(uv + vec2(cos(a), sin(a)) * r * radius); + } + return c / f32(TAPS); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let res = u.resolution.xy / u.resolution.w; + let uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5); + let pt = uv * res; + let t = u.live.x; + + let island = u.params0.xy * res; + let islandSize = u.params0.zw * res; + + let x = max(t - START, 0.0); + let spring = 1.0 - (1.0 + STIFFNESS * x) * exp(-STIFFNESS * x); + let grow = clamp(spring / SPRING_END, 0.0, 1.0); + let size = res * mix(0.16, 1.0, grow); + let top = mix(island.y - islandSize.y * 0.5, 0.0, grow); + let cardMin = vec2((res.x - size.x) * 0.5, top); + let cardMax = cardMin + size; + let ext = (cardMax - cardMin) * 0.5; + let center = (cardMin + cardMax) * 0.5; + let radius = mix(56.0, 0.0, smoothstep(0.96, 1.0, grow)); + let sd = rounded(pt - center, ext, min(radius, min(ext.x, ext.y))); + let edge = 0.8 + 24.0 * (1.0 - smoothstep(0.08, 0.26, x)); + let fadeIn = smoothstep(0.0, 0.12, x) * step(START, t); + let coverage = (1.0 - smoothstep(-edge, edge, sd)) * fadeIn; + + if (coverage <= 0.0) { + return vec4(0.0); + } + + let local = (pt - cardMin) / max(cardMax - cardMin, vec2(1.0)); + let focus = 1.0 - smoothstep(0.05, 0.36, x); + let blurPt = 30.0 * focus; + let spin = fract(52.9829 * fract(dot(pt * u.resolution.w, vec2(0.06711, 0.00584)))) * 6.2832; + var col = defocus(local, vec2(blurPt) / res, spin); + return vec4(col * coverage, coverage); +} +`; diff --git a/example/src/components/polaroid/PolaroidCamera.tsx b/example/src/components/polaroid/PolaroidCamera.tsx new file mode 100644 index 0000000..c59a64a --- /dev/null +++ b/example/src/components/polaroid/PolaroidCamera.tsx @@ -0,0 +1,290 @@ +import { useImperativeHandle, useRef, useState, type Ref } from 'react'; +import { + Image, + Linking, + Pressable, + StyleSheet, + Text, + View, + type ImageSourcePropType, +} from 'react-native'; +import type { CameraView } from 'expo-camera'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSequence, + withTiming, +} from 'react-native-reanimated'; + +export type PolaroidCameraHandle = { + /** Null when no real camera is available or the capture failed. */ + takePhoto: () => Promise; + blink: () => void; +}; + +type ExpoCamera = typeof import('expo-camera'); + +type LiveCapture = Pick; + +/** Throws on a binary built before expo-camera was added. */ +function loadExpoCamera(): ExpoCamera | null { + try { + return require('expo-camera'); + } catch { + return null; + } +} + +const expoCamera = loadExpoCamera(); + +/** The classic stripe, top to bottom. */ +const STRIPE = ['#e2402c', '#f08a24', '#f6c73a', '#4aa564', '#2f7fc1']; +const WINDOW_SIZE = 164; +export const CAMERA_BODY_HEIGHT = 216; + +function CameraPrompt({ + canAsk, + onAsk, +}: { + canAsk: boolean; + onAsk: () => void; +}) { + return ( + + + {canAsk ? 'Use camera' : 'Open Settings'} + + + ); +} + +/** The live feed and the permission prompt, over the fallback photo. */ +function LiveViewfinder({ + ref, + expo, +}: { + ref: Ref; + expo: ExpoCamera; +}) { + const [permission, requestPermission] = expo.useCameraPermissions(); + const [ready, setReady] = useState(false); + const camera = useRef(null); + const allowed = permission?.granted === true; + + useImperativeHandle( + ref, + () => ({ + takePhoto: async () => { + if (!ready) { + return null; + } + const photo = await camera.current?.takePictureAsync({ + quality: 0.8, + shutterSound: false, + }); + return photo?.uri ?? null; + }, + }), + [ready] + ); + + return ( + <> + {allowed ? ( + setReady(true)} + /> + ) : null} + {permission && !allowed ? ( + + ) : null} + + ); +} + +/** + * The back of the camera: print slot along the top edge, a square viewfinder + * and the shutter. The window shows the live camera once allowed. Without + * permission or a working session (the simulator never gets one, and would + * return a generated placeholder image) it shows the bundled photo the next + * shot will print. + */ +export default function PolaroidCamera({ + ref, + fallbackPhoto, + bottomInset, + slotWidth, + onShutter, +}: { + ref: Ref; + fallbackPhoto: ImageSourcePropType; + bottomInset: number; + slotWidth: number; + onShutter: () => void; +}) { + const live = useRef(null); + const shade = useSharedValue(0); + + useImperativeHandle( + ref, + () => ({ + takePhoto: async () => (await live.current?.takePhoto()) ?? null, + blink: () => { + shade.value = withSequence( + withTiming(1, { duration: 40 }), + withTiming(0, { duration: 220 }) + ); + }, + }), + [shade] + ); + + const shadeStyle = useAnimatedStyle(() => ({ opacity: shade.value })); + + return ( + + + + {STRIPE.map((color) => ( + + ))} + + + + + + {expoCamera ? : null} + + + + + [ + styles.shutterRing, + pressed && styles.shutterPressed, + ]} + > + + + + ); +} + +const styles = StyleSheet.create({ + body: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + paddingTop: 26, + backgroundColor: '#191817', + borderTopLeftRadius: 26, + borderTopRightRadius: 26, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(255,255,255,0.22)', + shadowColor: '#000', + shadowOpacity: 0.6, + shadowRadius: 18, + shadowOffset: { width: 0, height: -6 }, + }, + slot: { + position: 'absolute', + top: 8, + height: 5, + borderRadius: 2.5, + backgroundColor: '#050505', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: 'rgba(255,255,255,0.14)', + }, + stripe: { + position: 'absolute', + left: 34, + top: 0, + bottom: 0, + width: 30, + flexDirection: 'row', + }, + band: { + flex: 1, + }, + bezel: { + padding: 6, + borderRadius: 14, + backgroundColor: '#0a0a0a', + borderWidth: StyleSheet.hairlineWidth, + borderColor: 'rgba(255,255,255,0.16)', + }, + window: { + width: WINDOW_SIZE, + height: WINDOW_SIZE, + borderRadius: 9, + overflow: 'hidden', + backgroundColor: '#000', + }, + shade: { + ...StyleSheet.absoluteFill, + backgroundColor: '#000', + }, + prompt: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingVertical: 11, + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.62)', + }, + promptText: { + color: '#fff', + fontSize: 15, + fontWeight: '600', + }, + shutterRing: { + position: 'absolute', + right: 26, + top: 72, + width: 74, + height: 74, + borderRadius: 37, + backgroundColor: '#0d0d0d', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: 'rgba(255,255,255,0.18)', + }, + shutterPressed: { + transform: [{ scale: 0.94 }], + }, + shutter: { + width: 52, + height: 52, + borderRadius: 26, + backgroundColor: '#e2402c', + borderWidth: 3, + borderColor: '#b52d1d', + }, +}); diff --git a/example/src/components/polaroid/PolaroidPrint.tsx b/example/src/components/polaroid/PolaroidPrint.tsx new file mode 100644 index 0000000..d74a420 --- /dev/null +++ b/example/src/components/polaroid/PolaroidPrint.tsx @@ -0,0 +1,159 @@ +import type { ImageSourcePropType } from 'react-native'; +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +/** An i-Type print: 88 x 107 mm, a 79 mm square image, 22 mm chin. */ +export const PRINT_ASPECT = 88 / 107; + +type PolaroidPrintProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'texture' +> & { + photo: ImageSourcePropType; + /** `u.live = (development 0..1, sway, seed, 0)`. */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A Polaroid print developing. + * + * The frame is paper: off-white, fibrous, with the image window pressed + * slightly into it. Inside the window the photo comes up out of the milky + * blue-grey opacifier the way the chemistry does it: shadows first, the dye + * layers in order (blue, then green, red last, so the early image is cold + * and cyan), contrast building as it goes. The reagent is spread from the + * chin, so the bottom of the image is a little ahead, and the spread is never + * perfectly even. The finished print is soft, warm and vignetted. + */ +export default function PolaroidPrint({ + photo, + paramsSynchronizable, + ...rest +}: PolaroidPrintProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +const PRINT = vec2(88.0, 107.0); +const IMG_MIN = vec2(4.5, 6.0); +const IMG_SIZE = 79.0; + +fn hash(p: vec2) -> f32 { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +fn vnoise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let w = f * f * (3.0 - 2.0 * f); + let a = hash(i); + let b = hash(i + vec2(1.0, 0.0)); + let c = hash(i + vec2(0.0, 1.0)); + let d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); +} + +fn fbm(p0: vec2) -> f32 { + var p = p0; + var s = 0.0; + var amp = 0.5; + for (var i = 0; i < 3; i++) { + s += amp * vnoise(p); + p = p * 2.03 + vec2(17.0, 9.0); + amp *= 0.5; + } + return s; +} + +fn paper(mm: vec2) -> vec3 { + let fiber = vnoise(mm * vec2(14.0, 3.5)) * 0.5 + vnoise(mm * vec2(3.0, 11.0)) * 0.5; + let cloud = fbm(mm * 0.35); + var col = vec3(0.955, 0.948, 0.925) * (0.975 + 0.03 * cloud + 0.018 * fiber); + let edge = min(min(mm.x, PRINT.x - mm.x), min(mm.y, PRINT.y - mm.y)); + col *= 0.93 + 0.07 * smoothstep(0.0, 1.6, edge); + return col; +} + +fn develop(q: vec2, dev: f32, seed: f32) -> vec3 { + let photo = textureSampleLevel(tex, samp, q, 0.0).rgb; + let lum = dot(photo, vec3(0.299, 0.587, 0.114)); + + let mottle = fbm(q * 4.0 + vec2(seed * 13.0, seed * 7.0)) - 0.5; + let fromPod = q.y * 0.09; + let p = pow(dev, 1.5) * 1.2 - 0.06 + fromPod + mottle * 0.12 + (0.45 - lum) * 0.22; + + let pb = smoothstep(0.0, 0.55, p); + let pg = smoothstep(0.1, 0.72, p); + let pr = smoothstep(0.26, 0.98, p); + let contrast = mix(0.4, 1.0, smoothstep(0.15, 1.0, p)); + + var graded = photo * 0.9 + 0.05; + graded = vec3(lum) + (graded - vec3(lum)) * 0.88; + graded *= vec3(1.05, 1.0, 0.9); + let vd = length((q - 0.5) * vec2(1.0, 1.05)); + graded *= 1.0 - 0.38 * smoothstep(0.25, 0.78, vd); + let mid = vec3(0.42); + let target_col = mid + (graded - mid) * contrast; + + let base = vec3(0.44, 0.5, 0.5) + vec3(mottle * 0.04); + var col = vec3( + mix(base.r, target_col.r, pr), + mix(base.g, target_col.g, pg), + mix(base.b, target_col.b, pb) + ); + let grain = hash(floor(q * 900.0) + vec2(seed)) - 0.5; + col += grain * 0.025; + return clamp(col, vec3(0.0), vec3(1.0)); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let mm = vec2(uv.x, 1.0 - uv.y) * PRINT; + let dev = clamp(u.live.x, 0.0, 1.0); + let sway = u.live.y; + let seed = u.live.z; + + let rel = mm - IMG_MIN; + let inImg = rel.x >= 0.0 && rel.y >= 0.0 && rel.x <= IMG_SIZE && rel.y <= IMG_SIZE; + let dEdge = min(min(rel.x, IMG_SIZE - rel.x), min(rel.y, IMG_SIZE - rel.y)); + + var col = paper(mm); + let lip = smoothstep(0.0, 0.5, -dEdge) * (1.0 - smoothstep(0.5, 1.4, -dEdge)); + col *= 1.0 - 0.035 * lip; + + if (inImg) { + let q = rel / IMG_SIZE; + var img = develop(q, dev, seed); + img *= 0.8 + 0.2 * smoothstep(0.0, 0.9, dEdge); + let sheenPos = q.x * 0.7 + q.y * 0.3 - 0.35 + sway * 0.6; + let sheen = exp(-pow((sheenPos - 0.3) * 4.5, 2.0)); + img += vec3(0.05) * sheen; + col = img; + } + return vec4(col, 1.0); +} +`; diff --git a/example/src/components/polaroid/SquareCropper.tsx b/example/src/components/polaroid/SquareCropper.tsx new file mode 100644 index 0000000..f86c554 --- /dev/null +++ b/example/src/components/polaroid/SquareCropper.tsx @@ -0,0 +1,97 @@ +import { + useImperativeHandle, + useRef, + useState, + type ComponentRef, + type Ref, + type RefObject, +} from 'react'; +import { Image, StyleSheet, View } from 'react-native'; +import { captureRef } from 'react-native-view-shot'; + +export type SquareCropperHandle = { + crop: (uri: string) => Promise; +}; + +type Job = { + uri: string; + resolve: (uri: string) => void; + reject: (reason: unknown) => void; +}; + +/** Points; view-shot captures at screen scale, so about 720 px on a phone. */ +const SIZE = 240; + +async function captureSquare( + view: RefObject | null> +) { + const raw = await captureRef(view, { + format: 'jpg', + quality: 0.9, + result: 'tmpfile', + }); + return raw.startsWith('file://') ? raw : `file://${raw}`; +} + +/** + * Turns a full-size photo into a small centred square, the crop of an instant + * print and of the viewfinder. Eight full-size photos as GPU textures would + * cost hundreds of MB. Mount it behind something opaque: the image has to be + * on screen for view-shot to see it. + */ +export default function SquareCropper({ + ref, +}: { + ref: Ref; +}) { + const [job, setJob] = useState(null); + const view = useRef>(null); + + useImperativeHandle( + ref, + () => ({ + crop: (uri) => + new Promise((resolve, reject) => setJob({ uri, resolve, reject })), + }), + [] + ); + + if (!job) { + return null; + } + + const finish = () => { + requestAnimationFrame(() => { + captureSquare(view) + .then(job.resolve, job.reject) + .finally(() => setJob(null)); + }); + }; + + const fail = () => { + job.reject(new Error('photo failed to load')); + setJob(null); + }; + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + square: { + position: 'absolute', + left: 0, + top: 0, + width: SIZE, + height: SIZE, + }, +}); diff --git a/example/src/components/polaroid/cameraSamples.ts b/example/src/components/polaroid/cameraSamples.ts new file mode 100644 index 0000000..b29151b --- /dev/null +++ b/example/src/components/polaroid/cameraSamples.ts @@ -0,0 +1,69 @@ +const CLICK_SECONDS = 0.06; +const WHIRR_SECONDS = 0.6; +const CLICK_PEAK = 0.16; +const WHIRR_PEAK = 0.05; +const GEAR_TEETH_PER_SECOND = 120; + +function noise(length: number) { + return Float32Array.from({ length }, () => Math.random() * 2 - 1); +} + +function lowpass(input: Float32Array, cutoff: number, sampleRate: number) { + const k = 1 - Math.exp((-2 * Math.PI * cutoff) / sampleRate); + const output = new Float32Array(input.length); + let state = 0; + input.forEach((sample, i) => { + state += k * (sample - state); + output[i] = state; + }); + return output; +} + +function highpass(input: Float32Array, cutoff: number, sampleRate: number) { + const low = lowpass(input, cutoff, sampleRate); + return input.map((sample, i) => sample - low[i]!); +} + +function bandNoise(length: number, lo: number, hi: number, rate: number) { + return highpass(lowpass(noise(length), hi, rate), lo, rate); +} + +function normalize(samples: Float32Array, peak: number) { + const max = samples.reduce((m, s) => Math.max(m, Math.abs(s)), 1e-6); + return samples.map((s) => (s / max) * peak); +} + +/** Two soft ticks of the blades opening and closing. */ +export function shutterClick(sampleRate: number) { + const length = Math.floor(sampleRate * CLICK_SECONDS); + const open = bandNoise(length, 2500, 9000, sampleRate); + const close = bandNoise(length, 900, 3500, sampleRate); + const closeAt = 0.022; + const mixed = open.map((sample, i) => { + const t = i / sampleRate; + const opening = sample * Math.exp(-t / 0.0025); + const closing = + t < closeAt ? 0 : close[i]! * 0.7 * Math.exp(-(t - closeAt) / 0.006); + const tail = 1 - t / CLICK_SECONDS; + return (opening + closing) * tail; + }); + return normalize(mixed, CLICK_PEAK); +} + +/** Airy gear rattle: uneven noise bursts with no pitch to drone or slide. */ +export function filmWhirr(sampleRate: number) { + const length = Math.floor(sampleRate * WHIRR_SECONDS); + const air = bandNoise(length, 1400, 5200, sampleRate); + const teeth = Float32Array.from( + { length: Math.ceil(WHIRR_SECONDS * GEAR_TEETH_PER_SECOND) + 1 }, + () => 0.55 + Math.random() * 0.45 + ); + const shaped = air.map((sample, i) => { + const t = i / sampleRate; + const tooth = teeth[Math.floor(t * GEAR_TEETH_PER_SECOND)]!; + const attack = Math.min(1, t / 0.02); + const fade = Math.max(0, Math.min(1, (WHIRR_SECONDS - t) / 0.5)); + return sample * tooth * attack * Math.sin((fade * Math.PI) / 2) ** 2; + }); + return normalize(shaped, WHIRR_PEAK); +} diff --git a/example/src/components/polaroid/cameraSound.ts b/example/src/components/polaroid/cameraSound.ts new file mode 100644 index 0000000..cba78d8 --- /dev/null +++ b/example/src/components/polaroid/cameraSound.ts @@ -0,0 +1,67 @@ +import { + AudioContext, + type AudioBuffer, + type AudioBufferSourceNode, + type GainNode, +} from 'react-native-audio-api'; +import { filmWhirr, shutterClick } from './cameraSamples'; + +type Sounds = { ac: AudioContext; click: AudioBuffer; whirr: AudioBuffer }; +type Voice = { source: AudioBufferSourceNode; level: GainNode }; + +const RELEASE_SECONDS = 0.04; + +let sounds: Sounds | null = null; +let voice: Voice | null = null; + +function toBuffer(ac: AudioContext, samples: Float32Array) { + const buffer = ac.createBuffer(1, samples.length, ac.sampleRate); + buffer.copyToChannel(samples, 0); + return buffer; +} + +function loadSounds(): Sounds { + if (!sounds) { + const ac = new AudioContext(); + sounds = { + ac, + click: toBuffer(ac, shutterClick(ac.sampleRate)), + whirr: toBuffer(ac, filmWhirr(ac.sampleRate)), + }; + } + return sounds; +} + +export function stopCameraSound() { + if (!voice || !sounds) { + return; + } + const now = sounds.ac.currentTime; + voice.level.gain.setTargetAtTime(0, now, RELEASE_SECONDS / 4); + voice.source.stop(now + RELEASE_SECONDS); + voice = null; +} + +function play(pick: (loaded: Sounds) => AudioBuffer) { + try { + stopCameraSound(); + const loaded = loadSounds(); + const source = loaded.ac.createBufferSource(); + source.buffer = pick(loaded); + const level = loaded.ac.createGain(); + source.connect(level); + level.connect(loaded.ac.destination); + source.start(); + voice = { source, level }; + } catch { + voice = null; + } +} + +export function playShutterClick() { + play(({ click }) => click); +} + +export function playFilmWhirr() { + play(({ whirr }) => whirr); +} diff --git a/example/src/components/scratchoff/Flakes.tsx b/example/src/components/scratchoff/Flakes.tsx new file mode 100644 index 0000000..447c4c8 --- /dev/null +++ b/example/src/components/scratchoff/Flakes.tsx @@ -0,0 +1,61 @@ +import { StyleSheet, View } from 'react-native'; +import Animated, { + useAnimatedStyle, + type SharedValue, +} from 'react-native-reanimated'; + +/** Floats per flake in the shared state: x, y, angle (deg), size, life 0..1. */ +export const FLAKE_STRIDE = 5; +export const FLAKE_COUNT = 48; + +function Flake({ + index, + state, +}: { + index: number; + state: SharedValue; +}) { + const style = useAnimatedStyle(() => { + const o = index * FLAKE_STRIDE; + const s = state.value; + const life = s[o + 4] ?? 0; + const size = s[o + 3] ?? 0; + const angle = s[o + 2] ?? 0; + const facing = Math.abs(Math.cos((angle * Math.PI) / 90)); + return { + opacity: life > 0 ? Math.min(1, life * 3) : 0, + width: size, + height: size * 0.7, + backgroundColor: `rgb(${150 + 90 * facing}, ${152 + 90 * facing}, ${158 + 90 * facing})`, + transform: [ + { translateX: s[o] ?? 0 }, + { translateY: s[o + 1] ?? 0 }, + { rotate: `${angle}deg` }, + ], + }; + }); + return ; +} + +/** + * Crumbs of latex flicked off by the finger: small silver chips that tumble + * (their shade flips as they turn) and fall away under gravity. + */ +export default function Flakes({ state }: { state: SharedValue }) { + return ( + + {Array.from({ length: FLAKE_COUNT }, (_, i) => ( + + ))} + + ); +} + +const styles = StyleSheet.create({ + flake: { + position: 'absolute', + left: 0, + top: 0, + borderRadius: 1, + }, +}); diff --git a/example/src/components/scratchoff/ScratchCoating.tsx b/example/src/components/scratchoff/ScratchCoating.tsx new file mode 100644 index 0000000..ae17d5f --- /dev/null +++ b/example/src/components/scratchoff/ScratchCoating.tsx @@ -0,0 +1,317 @@ +import { useMemo } from 'react'; +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +/** The play area, in points. Three prize boxes in a row, a bonus strip. */ +export const PLAY_W = 340; +export const PLAY_H = 182; +export const AREAS = [ + { x: 0, y: 0, w: 104, h: 104 }, + { x: 118, y: 0, w: 104, h: 104 }, + { x: 236, y: 0, w: 104, h: 104 }, + { x: 0, y: 118, w: 340, h: 64 }, +] as const; +export const AREA_RADIUS = 10; + +/** Coverage grid over the play area: one float per cell in `u.liveData`. */ +export const GRID_X = 24; +export const GRID_Y = 15; +/** `u.liveData[TILT_SLOT].xy` carries the tilt, after the grid. */ +export const TILT_SLOT = (GRID_X * GRID_Y) / 4; +export const CHANNEL_LENGTH = 4 + (TILT_SLOT + 1) * 4; + +export type PrizeSymbol = 0 | 1 | 2 | 3; + +type ScratchCoatingProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'params' | 'transparent' +> & { + /** + * `u.live` = reveal sweep 0..1 for each area; `u.liveData` = the coverage + * grid (row-major, 0..1), then the tilt (-1..1) at `TILT_SLOT`. + */ + paramsSynchronizable: ParamsSynchronizable; + /** Symbol under each area: star, clover, diamond, heart. */ + symbols: readonly [PrizeSymbol, PrizeSymbol, PrizeSymbol, PrizeSymbol]; +}; + +/** + * The silver latex over the prize areas, and the holographic symbols under it. + * + * Where the coverage grid says the finger has been, the latex is gone. The + * grid is coarse, so the edge is cut by two octaves of noise: scratched latex + * tears along its own grain into flaky, irregular edges and leaves specks + * behind. The remaining coat is metallic: fine grain, a printed lattice, a + * broad sheen and glitter that both move with tilt, a darker curled rim at + * the tear, and a shadow on the card just inside it. Under it each area has + * a hot-stamped holographic symbol: silver foil with fine diffraction grooves + * that throw saturated spectral bands racing across it with tilt, edged by a + * printed dark outline. An area's reveal sweep clears what is left in one + * diagonal pass with a line of sparkle. + */ +export default function ScratchCoating({ + paramsSynchronizable, + symbols, + ...rest +}: ScratchCoatingProps) { + const params = useMemo(() => [...symbols], [symbols]); + return ( + + ); +} + +function rect(i: number): string { + const a = AREAS[i]!; + return `vec4(${a.x}.0, ${a.y}.0, ${a.w}.0, ${a.h}.0)`; +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, + liveData: array, 96>, +}; +@group(0) @binding(0) var u: Uniforms; + +const PLAY = vec2(${PLAY_W}.0, ${PLAY_H}.0); +const GRID = vec2(${GRID_X}, ${GRID_Y}); +const RADIUS = ${AREA_RADIUS}.0; + +fn hash(p: vec2) -> f32 { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +fn vnoise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let w = f * f * (3.0 - 2.0 * f); + let a = hash(i); + let b = hash(i + vec2(1.0, 0.0)); + let c = hash(i + vec2(0.0, 1.0)); + let d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); +} + +fn fbm(p0: vec2) -> f32 { + var p = p0; + var s = 0.0; + var amp = 0.5; + for (var i = 0; i < 3; i++) { + s += amp * vnoise(p); + p = p * 2.1 + vec2(3.1, 7.7); + amp *= 0.5; + } + return s; +} + +fn cell(ix: i32, iy: i32) -> f32 { + let x = clamp(ix, 0, GRID.x - 1); + let y = clamp(iy, 0, GRID.y - 1); + let i = y * GRID.x + x; + return u.liveData[i / 4][i % 4]; +} + +fn coverage(pt: vec2) -> f32 { + let g = pt / PLAY * vec2(GRID) - 0.5; + let i = vec2(floor(g)); + let f = fract(g); + let a = cell(i.x, i.y); + let b = cell(i.x + 1, i.y); + let c = cell(i.x, i.y + 1); + let d = cell(i.x + 1, i.y + 1); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +fn sdRoundRect(p: vec2, r: vec4, rad: f32) -> f32 { + let c = r.xy + r.zw * 0.5; + let q = abs(p - c) - r.zw * 0.5 + rad; + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - rad; +} + +fn sdStar(p0: vec2) -> f32 { + let k1 = vec2(0.809016994, -0.587785252); + let k2 = vec2(-k1.x, k1.y); + var p = vec2(abs(p0.x), p0.y); + p -= 2.0 * max(dot(k1, p), 0.0) * k1; + p -= 2.0 * max(dot(k2, p), 0.0) * k2; + p = vec2(abs(p.x), p.y - 1.0); + let ba = 0.45 * vec2(-k1.y, k1.x) - vec2(0.0, 1.0); + let h = clamp(dot(p, ba) / dot(ba, ba), 0.0, 1.0); + return length(p - ba * h) * sign(p.y * ba.x - p.x * ba.y); +} + +fn sdClover(p: vec2) -> f32 { + let r = 0.36; + var d = length(p - vec2(0.0, 0.4)) - r; + d = min(d, length(p - vec2(0.4, 0.0)) - r); + d = min(d, length(p - vec2(-0.4, 0.0)) - r); + d = min(d, length(p - vec2(0.0, -0.4)) - r); + let stem = p - vec2(0.05, -0.55); + let h = clamp(dot(stem, vec2(0.32, -0.95)), 0.0, 0.5); + d = min(d, length(stem - vec2(0.32, -0.95) * h) - 0.07); + return d; +} + +fn sdDiamond(p: vec2) -> f32 { + let q = abs(p); + return (q.x * 0.95 + q.y * 0.68 - 0.75) * 0.82; +} + +fn dot2(v: vec2) -> f32 { + return dot(v, v); +} + +fn sdHeart(p0: vec2) -> f32 { + let p = vec2(abs(p0.x), p0.y * 1.05 + 0.62) / 1.25; + if (p.y + p.x > 1.0) { + return (sqrt(dot2(p - vec2(0.25, 0.75))) - 0.353553) * 1.25; + } + let d = sqrt(min(dot2(p - vec2(0.0, 1.0)), dot2(p - 0.5 * max(p.x + p.y, 0.0)))); + return d * sign(p.x - p.y) * 1.25; +} + +fn symbolSdf(kind: f32, q: vec2) -> f32 { + if (kind < 0.5) { + return sdStar(q * 1.05); + } + if (kind < 1.5) { + return sdClover(q); + } + if (kind < 2.5) { + return sdDiamond(q); + } + return sdHeart(q); +} + +fn areaRect(i: i32) -> vec4 { + switch (i) { + case 0: { return ${rect(0)}; } + case 1: { return ${rect(1)}; } + case 2: { return ${rect(2)}; } + default: { return ${rect(3)}; } + } +} + +fn symbolKind(i: i32) -> f32 { + return u.params0[i]; +} + +fn holoSymbol(pt: vec2, r: vec4, kind: f32, tilt: vec2) -> vec4 { + var c = r.xy + vec2(r.z * 0.5, r.w * 0.42); + var size = r.w * 0.27; + if (r.z > r.w * 2.0) { + c = r.xy + vec2(r.w * 0.62, r.w * 0.5); + size = r.w * 0.3; + } + let q = vec2(pt.x - c.x, c.y - pt.y) / size; + if (dot(q, q) > 2.6) { + return vec4(0.0); + } + let d = symbolSdf(kind, q); + let e = 0.02; + let grad = vec2( + symbolSdf(kind, q + vec2(e, 0.0)) - d, + symbolSdf(kind, q + vec2(0.0, e)) - d + ) / e; + let px = 1.2 / size; + let inside = 1.0 - smoothstep(-px, px, d); + let outline = 1.0 - smoothstep(px * 0.9, px * 2.0, abs(d + px * 0.5)); + + let bevel = 1.0 - smoothstep(0.0, 0.16, -d); + let n = normalize(vec3(-grad * bevel * 1.6, 1.0)); + + let grooveDir = normalize(vec2(0.8, 0.6)); + let across = dot(q, vec2(-grooveDir.y, grooveDir.x)); + let grooves = 0.5 + 0.5 * sin(across * size * 3.2); + let order = dot(q, grooveDir) * 0.9 + length(q) * 0.35 + n.x * 0.5 - n.y * 0.3 + tilt.x * 2.4 + tilt.y * 1.5; + let hue = fract(order); + let spectral = clamp(abs(fract(hue + vec3(0.0, 0.6667, 0.3333)) * 6.0 - 3.0) - 1.0, vec3(0.0), vec3(1.0)); + let band = smoothstep(0.15, 0.7, 0.5 + 0.5 * cos(6.2832 * order * 0.8)); + + let silver = vec3(0.74, 0.76, 0.8) * (0.8 + 0.25 * n.z) * (0.93 + 0.1 * grooves); + var col = mix(silver, mix(spectral, vec3(1.0), 0.22) * (0.8 + 0.3 * grooves), band * 0.9); + let light = normalize(vec3(-0.4 + tilt.x * 0.6, 0.5 - tilt.y * 0.6, 0.8)); + let spec = min(pow(max(dot(n, light), 0.0), 30.0), 1.0); + col += vec3(spec * 0.45); + let ink = vec3(0.06, 0.09, 0.08); + let alpha = max(inside, outline); + let rgb = mix(ink, col, inside * (1.0 - outline * 0.8)); + return vec4(rgb * alpha, alpha); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let pt = vec2(uv.x, 1.0 - uv.y) * PLAY; + let tilt = u.liveData[${TILT_SLOT}].xy; + + var area = -1; + var r = vec4(0.0); + for (var i = 0; i < 4; i++) { + let ri = areaRect(i); + if (sdRoundRect(pt, ri, RADIUS) < 0.0) { + area = i; + r = ri; + } + } + if (area < 0) { + return vec4(0.0); + } + let aa = 1.0 - smoothstep(-0.8, 0.4, sdRoundRect(pt, r, RADIUS)); + + let n1 = fbm(pt * 0.16); + let n2 = vnoise(pt * 0.55); + var e = coverage(pt) + (n1 - 0.5) * 0.62 + (n2 - 0.5) * 0.2; + + let reveal = u.live[area]; + let local = (pt - r.xy) / r.zw; + let diag = local.x * 0.72 + local.y * 0.28 + (n1 - 0.5) * 0.16; + let front = reveal * 1.45 - 0.2; + e = max(e, smoothstep(front + 0.04, front - 0.04, diag) * step(0.001, reveal)); + let speck = step(0.9, n2) * step(e, 0.95) * (1.0 - reveal); + let coat = max(1.0 - smoothstep(0.48, 0.52, e), speck * 0.9); + + var under = holoSymbol(pt, r, symbolKind(area), tilt); + let shadow = (1.0 - smoothstep(0.52, 0.64, e)) * (1.0 - coat) * 0.3; + under = vec4(under.rgb * (1.0 - shadow), under.a + (1.0 - under.a) * shadow); + + let grain = hash(floor(pt * 2.5)); + var silver = 0.71 + 0.07 * n1 + 0.05 * (grain - 0.5); + let lp = abs(fract((pt.x + pt.y) / 9.0) - 0.5); + let lq = abs(fract((pt.x - pt.y) / 9.0) - 0.5); + silver -= 0.035 * (1.0 - smoothstep(0.0, 0.06, min(lp, lq))); + let pn = pt / PLAY.x; + let s = pn.x * 0.8 + pn.y * 0.6 - 0.55 + tilt.x * 0.75 - tilt.y * 0.5; + let sheen = exp(-s * s * 14.0) * 0.16 + exp(-s * s * 120.0) * 0.07; + let gcell = floor(pt * 0.8); + let gh = hash(gcell + vec2(9.0, 2.0)); + let glitter = step(0.95, gh) * min(pow(max(cos(gh * 41.0 + tilt.x * 9.0 + tilt.y * 7.0), 0.0), 14.0), 1.0) * 0.3; + var coatCol = vec3(silver) * vec3(0.98, 0.99, 1.03) + vec3(sheen + glitter); + let rim = smoothstep(0.36, 0.48, e) * (1.0 - speck); + coatCol *= 1.0 - 0.22 * rim; + + let sweepGlow = exp(-pow((diag - front) * 18.0, 2.0)) * step(0.001, reveal) * step(reveal, 0.999); + let sparkle = step(0.82, hash(floor(pt * 0.5) + vec2(floor(reveal * 20.0)))) * sweepGlow; + let glowAmt = clamp(sweepGlow * 0.35 + sparkle * 0.9, 0.0, 1.0); + + var rgb = mix(under.rgb, coatCol, coat); + var alpha = mix(under.a, 1.0, coat); + rgb += vec3(1.0, 0.97, 0.88) * glowAmt; + alpha = max(alpha, glowAmt); + return vec4(min(rgb, vec3(alpha)) * aa, alpha * aa); +} +`; diff --git a/example/src/components/scratchoff/scratchSound.ts b/example/src/components/scratchoff/scratchSound.ts new file mode 100644 index 0000000..5832f14 --- /dev/null +++ b/example/src/components/scratchoff/scratchSound.ts @@ -0,0 +1,57 @@ +import { + AudioContext, + type AudioBufferSourceNode, + type BiquadFilterNode, + type GainNode, +} from 'react-native-audio-api'; + +type Voice = { + ctx: AudioContext; + source: AudioBufferSourceNode; + tone: BiquadFilterNode; + level: GainNode; +}; + +let voice: Voice | null = null; + +function createVoice(): Voice { + const ctx = new AudioContext(); + const length = ctx.sampleRate; + const buffer = ctx.createBuffer(1, length, ctx.sampleRate); + const data = buffer.getChannelData(0); + let grit = 0; + for (let i = 0; i < length; i++) { + grit = Math.random() < 0.02 ? Math.random() : grit * 0.96; + data[i] = (Math.random() * 2 - 1) * (0.35 + 0.65 * grit); + } + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.loop = true; + const tone = ctx.createBiquadFilter(); + tone.type = 'bandpass'; + tone.frequency.value = 2600; + tone.Q.value = 0.7; + const level = ctx.createGain(); + level.gain.value = 0; + source.connect(tone); + tone.connect(level); + level.connect(ctx.destination); + source.start(); + return { ctx, source, tone, level }; +} + +/** + * The rasp of a coin on latex: looped crackly noise through a bandpass whose + * loudness and brightness follow how fast the finger moves (pt/s). + */ +export function setScratchSpeed(speed: number) { + try { + voice = voice ?? createVoice(); + const amount = Math.min(1, speed / 900); + const now = voice.ctx.currentTime; + voice.level.gain.setTargetAtTime(amount * 0.5, now, 0.03); + voice.tone.frequency.setTargetAtTime(1800 + amount * 2600, now, 0.05); + } catch { + // No audio: scratching still works, just silently. + } +} diff --git a/example/src/components/wallet/CardArt.tsx b/example/src/components/wallet/CardArt.tsx new file mode 100644 index 0000000..b34a043 --- /dev/null +++ b/example/src/components/wallet/CardArt.tsx @@ -0,0 +1,258 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { CARD_RADIUS } from './CardSurface'; + +/** EMV chip: a gold pad with its contact segments. */ +function Chip() { + return ( + + + + + + + ); +} + +/** The contactless mark: four nested arcs opening to the right. */ +function Contactless() { + return ( + + {[8, 14, 20, 26].map((size) => ( + + ))} + + ); +} + +/** Two overlapping discs with the orange lens where they meet. */ +function Network() { + return ( + + + + + + + ); +} + +/** Printed and engraved details laid over the metal surface. */ +export function CardArt() { + return ( + + Neobank + Titanium + + + + + •••• 1834 + + + ); +} + +export type StackCardSpec = { + name: string; + color: string; + ink: string; + mark: string; +}; + +/** A card waiting in the stack: only its top edge and name show. */ +export function StackCard({ + spec, + top, + width, + height, +}: { + spec: StackCardSpec; + top: number; + width: number; + height: number; +}) { + return ( + + + {spec.name} + {spec.mark} + + ); +} + +const LENS = 34 - 13; + +const styles = StyleSheet.create({ + bank: { + position: 'absolute', + left: 22, + top: 20, + color: 'rgba(235, 237, 240, 0.86)', + fontSize: 20, + fontWeight: '600', + letterSpacing: 0.2, + textShadowColor: 'rgba(0, 0, 0, 0.9)', + textShadowOffset: { width: 0, height: -0.6 }, + textShadowRadius: 0.4, + }, + tier: { + position: 'absolute', + right: 22, + top: 24, + color: 'rgba(220, 224, 230, 0.55)', + fontSize: 13, + fontWeight: '500', + letterSpacing: 1.6, + }, + chipWrap: { + position: 'absolute', + left: 22, + top: 92, + flexDirection: 'row', + alignItems: 'center', + }, + chip: { + width: 46, + height: 35, + borderRadius: 7, + backgroundColor: '#c8b27c', + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#8a7444', + overflow: 'hidden', + }, + chipLineH: { + position: 'absolute', + left: 0, + right: 0, + top: 17, + height: 1, + backgroundColor: 'rgba(110, 88, 40, 0.7)', + }, + chipLineV: { + position: 'absolute', + top: 0, + bottom: 0, + width: 1, + backgroundColor: 'rgba(110, 88, 40, 0.7)', + }, + chipLineLeft: { + left: 15, + }, + chipLineRight: { + right: 15, + }, + chipCore: { + position: 'absolute', + left: 15, + top: 10, + width: 16, + height: 15, + borderRadius: 3, + borderWidth: 1, + borderColor: 'rgba(110, 88, 40, 0.7)', + backgroundColor: '#d4bf8c', + }, + waves: { + width: 20, + height: 30, + marginLeft: 16, + overflow: 'hidden', + }, + wave: { + position: 'absolute', + left: 0, + top: 15, + borderWidth: 2, + borderColor: 'transparent', + borderRightColor: 'rgba(225, 228, 232, 0.7)', + }, + digits: { + position: 'absolute', + left: 22, + bottom: 22, + color: 'rgba(235, 237, 240, 0.85)', + fontSize: 16, + fontWeight: '500', + letterSpacing: 1.2, + fontVariant: ['tabular-nums'], + }, + network: { + position: 'absolute', + right: 20, + bottom: 18, + width: 34 * 2 - 13, + height: 34, + }, + disc: { + position: 'absolute', + top: 0, + width: 34, + height: 34, + borderRadius: 17, + overflow: 'hidden', + }, + discRed: { + left: 0, + backgroundColor: '#eb001b', + }, + discYellow: { + left: LENS, + backgroundColor: '#f79e1b', + }, + lens: { + left: -LENS, + backgroundColor: '#ff5f00', + }, + stackCard: { + position: 'absolute', + left: 0, + borderRadius: CARD_RADIUS, + overflow: 'hidden', + shadowColor: '#000', + shadowOpacity: 0.6, + shadowRadius: 10, + shadowOffset: { width: 0, height: -4 }, + }, + stackSheen: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + height: 1, + backgroundColor: 'rgba(255, 255, 255, 0.35)', + }, + stackName: { + position: 'absolute', + left: 18, + top: 12, + fontSize: 16, + fontWeight: '700', + letterSpacing: -0.2, + }, + stackMark: { + position: 'absolute', + right: 18, + top: 11, + fontSize: 16, + fontWeight: '800', + fontStyle: 'italic', + }, +}); diff --git a/example/src/components/wallet/CardSurface.tsx b/example/src/components/wallet/CardSurface.tsx new file mode 100644 index 0000000..4565b80 --- /dev/null +++ b/example/src/components/wallet/CardSurface.tsx @@ -0,0 +1,134 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +export const CARD_RADIUS = 14; + +type CardSurfaceProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' +> & { + /** `u.live = (tiltX -1..1, tiltY -1..1, glint 0..1, lift 0..1)`. */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * The face of a brushed black titanium card. + * + * Near-black metal brushed along its length, so the light on it is a crisp + * anisotropic streak across the grain that slides with tilt, broken into + * lines by the brushing. Only at steep angles does a faint thin-film tint + * creep into the streak. A thin bright bevel runs round the edge. `glint` + * runs a sharp band of light diagonally across once, the way the card catches + * the light when Wallet lifts it. + */ +export default function CardSurface({ + paramsSynchronizable, + ...rest +}: CardSurfaceProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; + +const CORNER_RADIUS = ${CARD_RADIUS}.0; + +fn hash(p: vec2) -> f32 { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +fn vnoise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let w = f * f * (3.0 - 2.0 * f); + let a = hash(i); + let b = hash(i + vec2(1.0, 0.0)); + let c = hash(i + vec2(0.0, 1.0)); + let d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); +} + +fn fbm(p0: vec2) -> f32 { + var p = p0; + var s = 0.0; + var amp = 0.5; + for (var i = 0; i < 4; i++) { + s += amp * vnoise(p); + p = p * 2.02 + vec2(5.3, 1.7); + amp *= 0.5; + } + return s; +} + +fn brushing(uv: vec2, px: vec2) -> f32 { + let row = uv.y * px.y; + let fine = hash(vec2(floor(row), 1.0)); + let streaks = vnoise(vec2(uv.x * 3.0, row * 0.35)) * 0.6 + vnoise(vec2(uv.x * 9.0, row * 0.9)) * 0.4; + return fine * 0.45 + streaks * 0.55; +} + +fn cornerDistance(uv: vec2, size: vec2, radius: f32) -> f32 { + let q = abs((uv - 0.5) * size) - (size * 0.5 - vec2(radius)); + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - radius; +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let uv = ndc * 0.5 + 0.5; + let aspect = u.resolution.z; + let p = (uv - 0.5) * vec2(aspect, 1.0); + let tilt = clamp(u.live.xy, vec2(-1.0), vec2(1.0)); + let glint = u.live.z; + let lift = u.live.w; + + let brush = brushing(uv, u.resolution.xy); + let cloud = fbm(p * 2.2 + vec2(4.0, 1.0)); + var col = vec3(0.055, 0.058, 0.064) * (0.9 + 0.25 * cloud); + col *= 0.86 + 0.28 * brush; + col = mix(col, col * 1.35, smoothstep(-0.5, 0.5, p.y) * 0.4); + + let sx = 0.35 - tilt.x * 0.95; + let sd = p.x - sx + p.y * 0.32 + tilt.y * 0.2; + let core = exp(-sd * sd * 140.0); + let wide = exp(-sd * sd * 9.0); + let lines = 0.55 + 0.9 * brush; + var spec = (core * 0.42 + wide * 0.1) * lines; + + let graze = smoothstep(0.35, 1.0, length(tilt)); + let film = 0.5 + 0.5 * cos(6.2832 * (vec3(0.0, 0.33, 0.67) + sd * 1.6 + tilt.x * 0.5 + 0.55)); + let tint = mix(vec3(1.0), film, 0.06 + 0.4 * graze); + col += tint * spec * vec3(0.95, 0.97, 1.0); + col += film * wide * graze * 0.05; + + let g = mix(-1.5, 1.5, glint); + let gd = (p.x * 0.8 + p.y * 0.6) - g; + let sweep = exp(-gd * gd * 260.0) * 0.5 + exp(-gd * gd * 16.0) * 0.08; + col += vec3(sweep * sin(3.14159 * clamp(glint, 0.0, 1.0))) * lines; + + let edge = cornerDistance(uv, u.resolution.xy, CORNER_RADIUS * u.resolution.w); + let coverage = clamp(0.5 - edge, 0.0, 1.0); + let rim = 1.0 - smoothstep(0.0, 2.5 * u.resolution.w, -edge); + col += vec3(0.22) * rim * (0.35 + 0.65 * uv.y + spec); + col *= 1.0 + 0.05 * lift; + return vec4(clamp(col, vec3(0.0), vec3(1.0)) * coverage, coverage); +} +`; diff --git a/example/src/components/wallet/PayGlyph.tsx b/example/src/components/wallet/PayGlyph.tsx new file mode 100644 index 0000000..88bc284 --- /dev/null +++ b/example/src/components/wallet/PayGlyph.tsx @@ -0,0 +1,169 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +type PayGlyphProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'transparent' +> & { + /** + * `u.live = (face, ring, check, reader)` and + * `u.liveData[0] = (done, spin phase, nod, 0)`, every amount 0..1. + */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * The status glyph of the Apple Pay sheet, drawn as distance fields so each + * stage can morph into the next: the Face ID face (its corner brackets close + * into a spinning ring while it scans, then a check mark draws), the + * iPhone-in-a-ring reader glyph with a soft pulse for Hold Near Reader, and + * the blue disc with its check for Done. + */ +export default function PayGlyph({ + paramsSynchronizable, + ...rest +}: PayGlyphProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, + liveData: array, 96>, +}; +@group(0) @binding(0) var u: Uniforms; + +const STROKE = 0.05; +const BLUE = vec3(0.04, 0.52, 1.0); + +fn segment(p: vec2, a: vec2, b: vec2) -> f32 { + let pa = p - a; + let ba = b - a; + let h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + return length(pa - ba * h); +} + +fn arc(p: vec2, c: vec2, r: f32, a0: f32, a1: f32) -> f32 { + let d = p - c; + let ang = atan2(d.y, d.x); + if (ang >= a0 && ang <= a1) { + return abs(length(d) - r); + } + let e0 = c + r * vec2(cos(a0), sin(a0)); + let e1 = c + r * vec2(cos(a1), sin(a1)); + return min(length(p - e0), length(p - e1)); +} + +fn brackets(c: vec2) -> f32 { + let q = abs(c); + let corner = arc(q, vec2(0.56), 0.24, 0.0, 1.5708); + let side = min( + segment(q, vec2(0.8, 0.56), vec2(0.8, 0.34)), + segment(q, vec2(0.56, 0.8), vec2(0.34, 0.8)) + ); + return min(corner, side); +} + +fn face(c: vec2, nod: f32) -> f32 { + let o = vec2(nod * 0.06, nod * 0.03); + let eyes = min( + segment(c, vec2(-0.27, 0.24) + o, vec2(-0.27, 0.1) + o), + segment(c, vec2(0.27, 0.24) + o, vec2(0.27, 0.1) + o) + ); + let nose = min( + segment(c, vec2(0.02, 0.24) + o, vec2(0.02, -0.08) + o), + segment(c, vec2(0.02, -0.08) + o, vec2(-0.07, -0.08) + o) + ); + let mouth = arc(c - o, vec2(0.0, 0.06), 0.36, -2.25, -0.89); + return min(eyes, min(nose, mouth)); +} + +fn check(c: vec2, amount: f32, scale: f32) -> f32 { + let a = vec2(-0.42, 0.0) * scale; + let b = vec2(-0.12, -0.3) * scale; + let e = vec2(0.46, 0.32) * scale; + let l1 = length(b - a); + let l2 = length(e - b); + let drawn = amount * (l1 + l2); + var d = 1e3; + if (drawn > 0.0) { + d = segment(c, a, a + (b - a) * min(drawn / l1, 1.0)); + } + if (drawn > l1) { + d = min(d, segment(c, b, b + (e - b) * min((drawn - l1) / l2, 1.0))); + } + return d; +} + +fn ink(d: f32, w: f32, px: f32) -> f32 { + return 1.0 - smoothstep(w - px, w + px, d); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let c = ndc * vec2(u.resolution.z, 1.0) * 1.05; + let px = 2.4 / u.resolution.y; + let faceAmt = u.live.x; + let ring = u.live.y; + let checkAmt = u.live.z; + let reader = u.live.w; + let done = u.liveData[0].x; + let spin = u.liveData[0].y; + let nod = u.liveData[0].z; + + var white = 0.0; + var blue = 0.0; + + let r = length(c); + let ang = atan2(c.y, c.x); + let dashes = abs(fract((ang + spin) / 6.2832 * 16.0) - 0.5) * 2.0; + let ringD = max(abs(r - 0.78), (dashes - 0.55) * 0.12); + let shapeD = mix(brackets(c), ringD, ring); + let frameVis = max(faceAmt, checkAmt) * (1.0 - checkAmt * ring); + white = max(white, ink(shapeD, STROKE, px) * frameVis); + white = max(white, ink(face(c, nod), STROKE, px) * faceAmt * (1.0 - ring)); + white = max(white, ink(check(c, checkAmt, 0.9), STROKE * 1.2, px) * step(0.001, checkAmt)); + + let pulse = fract(u.time.x * 0.7); + let phoneQ = abs(c - vec2(0.0, -0.02)) - vec2(0.25, 0.43) + 0.11; + let phoneD = length(max(phoneQ, vec2(0.0))) + min(max(phoneQ.x, phoneQ.y), 0.0) - 0.11; + let phoneFill = 1.0 - smoothstep(-px, px, phoneD); + let island = segment(c, vec2(-0.07, 0.3), vec2(0.07, 0.3)); + var readerInk = ink(abs(phoneD), STROKE * 0.75, px); + readerInk = max(readerInk, ink(island, STROKE * 0.6, px)); + readerInk = max(readerInk, ink(abs(r - 0.86), STROKE * 0.9, px)); + blue = max(blue, readerInk * reader); + blue = max(blue, phoneFill * 0.22 * reader); + let ringR = 0.86 + pulse * 0.16; + let ripple = ink(abs(r - ringR), STROKE * 0.6, px) * (1.0 - pulse) * 0.6 * reader; + blue = max(blue, ripple); + + let disc = (1.0 - smoothstep(0.82 * done - px, 0.82 * done + px, r)) * step(0.001, done); + let tick = ink(check(c, clamp(done * 1.6 - 0.4, 0.0, 1.0), 0.75), STROKE * 1.25, px); + + var col = mix(BLUE, vec3(0.92), step(0.001, white)); + var alpha = white; + col = mix(col, BLUE, blue); + alpha = max(alpha, blue); + col = mix(col, BLUE, disc); + alpha = max(alpha, disc); + col = mix(col, vec3(1.0), tick * disc); + return vec4(col * alpha, alpha); +} +`; diff --git a/example/src/components/wallet/chime.ts b/example/src/components/wallet/chime.ts new file mode 100644 index 0000000..fe23cfc --- /dev/null +++ b/example/src/components/wallet/chime.ts @@ -0,0 +1,29 @@ +import { AudioContext } from 'react-native-audio-api'; + +let ctx: AudioContext | null = null; + +function tone(ac: AudioContext, freq: number, at: number, length: number) { + const osc = ac.createOscillator(); + osc.type = 'sine'; + osc.frequency.value = freq; + const env = ac.createGain(); + env.gain.setValueAtTime(0, at); + env.gain.linearRampToValueAtTime(0.18, at + 0.008); + env.gain.exponentialRampToValueAtTime(0.001, at + length); + osc.connect(env); + env.connect(ac.destination); + osc.start(at); + osc.stop(at + length + 0.02); +} + +/** A soft two-note success chime, in the spirit of the Apple Pay ding. */ +export function playChime() { + try { + ctx = ctx ?? new AudioContext(); + const t0 = ctx.currentTime + 0.01; + tone(ctx, 1318.5, t0, 0.5); + tone(ctx, 1975.5, t0 + 0.09, 0.7); + } catch { + // Silent fallback: the haptic and check mark still land. + } +} diff --git a/example/src/hooks/useBubbleWrap.ts b/example/src/hooks/useBubbleWrap.ts new file mode 100644 index 0000000..be1f862 --- /dev/null +++ b/example/src/hooks/useBubbleWrap.ts @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useParamsSynchronizable } from 'react-native-effects'; + +/** Target bubble pitch, points. */ +const TARGET_SPACING = 46; +/** `u.liveData` holds 384 floats, one per bubble. */ +const MAX_BUBBLES = 384; +/** How long a bubble takes to crumple flat (s). */ +const POP_DURATION = 0.14; + +export type BubbleGrid = { + spacing: number; + rowHeight: number; + /** Bubbles per row, including the half bubble at the right edge. */ + stride: number; + rows: number; +}; + +function gridFor(width: number, height: number, cols: number): BubbleGrid { + const spacing = width / cols; + const rowHeight = spacing * 0.8660254; + const rows = Math.ceil(height / rowHeight) + 1; + return { spacing, rowHeight, stride: cols + 1, rows }; +} + +function layoutGrid(width: number, height: number): BubbleGrid { + let cols = Math.max(4, Math.round(width / TARGET_SPACING)); + let grid = gridFor(width, height, cols); + while (grid.stride * grid.rows > MAX_BUBBLES && cols > 4) { + cols -= 1; + grid = gridFor(width, height, cols); + } + return grid; +} + +/** + * Bubble centre for (row, col), points. Even rows start half a bubble in, + * odd rows sit on the left edge, so both edges show cut bubbles like a sheet + * cut off the roll. + */ +function bubbleCenter(grid: BubbleGrid, row: number, col: number) { + const shift = row % 2 === 0 ? 0.5 : 0; + return { x: (col + shift) * grid.spacing, y: (row + 0.5) * grid.rowHeight }; +} + +function nearestBubble(grid: BubbleGrid, x: number, y: number): number { + const r0 = Math.floor(y / grid.rowHeight - 0.5); + let best = -1; + let bestD = Infinity; + for (let row = r0; row <= r0 + 1; row++) { + if (row < 0 || row >= grid.rows) { + continue; + } + const shift = row % 2 === 0 ? 0.5 : 0; + const col = Math.round(x / grid.spacing - shift); + if (col < 0 || col >= grid.stride) { + continue; + } + const c = bubbleCenter(grid, row, col); + const d = Math.hypot(x - c.x, y - c.y); + if (d < bestD) { + bestD = d; + best = row * grid.stride + col; + } + } + return bestD < grid.spacing * 0.58 ? best : -1; +} + +/** + * The state of a sheet of bubble wrap. + * + * Each bubble owns one float of the live channel (`u.liveData`, flattened): + * 0 is intact, 1 is popped flat, and in between it is crumpling. A press on + * a bubble pops it; dragging pops every bubble the finger rolls over, for + * every finger on the glass. + */ +export function useBubbleWrap( + width: number, + height: number, + onPop: (index: number) => void +) { + const grid = useMemo(() => layoutGrid(width, height), [width, height]); + const count = grid.stride * grid.rows; + + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable(new Array(4 + MAX_BUBBLES).fill(0)); + + const stateRef = useRef(new Float64Array(MAX_BUBBLES)); + const animatingRef = useRef(new Set()); + const onPopRef = useRef(onPop); + onPopRef.current = onPop; + + const flush = useCallback(() => { + setParamsSynchronizable(0, 0, 0, 0, ...stateRef.current); + }, [setParamsSynchronizable]); + + useEffect(() => { + let raf = 0; + let lastTs = 0; + const step = (now: number) => { + const dt = lastTs === 0 ? 0.016 : Math.min(0.05, (now - lastTs) / 1000); + lastTs = now; + const animating = animatingRef.current; + if (animating.size > 0) { + const state = stateRef.current; + for (const i of animating) { + state[i] = Math.min(1, (state[i] ?? 0) + dt / POP_DURATION); + if (state[i] === 1) { + animating.delete(i); + } + } + flush(); + } + raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [flush]); + + const pressAt = useCallback( + (x: number, y: number) => { + const i = nearestBubble(grid, x, y); + if (i < 0 || i >= count || (stateRef.current[i] ?? 0) > 0) { + return; + } + stateRef.current[i] = 0.001; + animatingRef.current.add(i); + onPopRef.current(i); + }, + [grid, count] + ); + + // Touches jump between move events (the first one by the whole touch + // slop), so press every bubble along the segment, not just its end. + const lastTouchRef = useRef(new Map()); + const dragTo = useCallback( + (id: string, x: number, y: number) => { + const last = lastTouchRef.current.get(id) ?? { x, y }; + const steps = Math.ceil( + Math.hypot(x - last.x, y - last.y) / (grid.spacing * 0.3) + ); + for (let k = 1; k <= steps; k++) { + const t = k / steps; + pressAt(last.x + (x - last.x) * t, last.y + (y - last.y) * t); + } + pressAt(x, y); + lastTouchRef.current.set(id, { x, y }); + }, + [grid, pressAt] + ); + + const releaseTouches = useCallback(() => { + lastTouchRef.current.clear(); + }, []); + + const reset = useCallback(() => { + stateRef.current.fill(0); + animatingRef.current.clear(); + flush(); + }, [flush]); + + const poppedCount = useCallback(() => { + let n = 0; + for (let i = 0; i < count; i++) { + if ((stateRef.current[i] ?? 0) > 0) { + n++; + } + } + return n; + }, [count]); + + // `__bubblePop(x, y)` presses the sheet at screen fraction (x, y), y down. + useEffect(() => { + if (!__DEV__) { + return; + } + const g = globalThis as { __bubblePop?: (x: number, y: number) => void }; + g.__bubblePop = (x, y) => pressAt(x * width, y * height); + return () => { + delete g.__bubblePop; + }; + }, [pressAt, width, height]); + + return { + paramsSynchronizable, + grid, + count, + pressAt, + dragTo, + releaseTouches, + reset, + poppedCount, + }; +} diff --git a/example/src/hooks/useCigarettePhysics.ts b/example/src/hooks/useCigarettePhysics.ts deleted file mode 100644 index d5fb185..0000000 --- a/example/src/hooks/useCigarettePhysics.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { useCallback, useEffect, useRef } from 'react'; -import { Accelerometer } from 'expo-sensors'; -import { useParamsSynchronizable } from 'react-native-effects'; - -/** A cigarette left alone burns to the filter in this long (s). */ -const SMOLDER_TIME = 360; -/** Pulling on it burns the tobacco this much faster on top. */ -const DRAW_TIME = 20; -/** How much of the tobacco's length the ash can grow to before it drops. */ -const ASH_MAX = 0.26; -/** Press shorter than this is a tap (flick the ash / light up), not a drag. */ -const TAP_MAX = 0.22; -/** A drag only starts to pull once the finger has been down this long (s). */ -const DRAW_DELAY = 0.1; -const DRAW_IN_RATE = 4.5; -const DRAW_OUT_RATE = 3.0; -/** How long the after-drag surge of smoke takes to climb out of the picture (s). */ -const PUFF_DURATION = 3.4; -/** How long a dropped piece of ash stays in the picture (s). */ -const CHUNK_DURATION = 1.6; -/** Fade out the spent butt, fade in a fresh one (s). */ -const SWAP_DURATION = 0.9; -const SWAP_CROSS = 0.4; -/** Match-flare on lighting up (s). */ -const FLARE_TAU = 0.45; -/** Ember envelope rates: catching is slow, dying at the filter slower. */ -const EMBER_IN_RATE = 1.6; -const EMBER_OUT_RATE = 0.7; -/** Spring that carries the phone's in-plane tilt (rad). */ -const TILT_STIFFNESS = 120; -const TILT_DAMPING = 16; - -type CigaretteSim = { - lit: boolean; - ember: number; - burn: number; - ash: number; - pressing: boolean; - pressStart: number; - draw: number; - puff: number; - puffAge: number; - /** The piece of ash in the air: its length, sideways kick and age. */ - chunkLen: number; - chunkKick: number; - chunkAge: number; - swapAge: number; - flare: number; - /** In-plane gravity angle from the sensor (rad), confidence-blended. */ - tiltTarget: number; - tilt: number; - tiltVel: number; - /** Integrated turbulence phase — never time x varying speed. */ - phase: number; -}; - -/** - * The physics of a lit cigarette. - * - * It smoulders on its own: the coal creeps down the tobacco, the ash grows - * above it and breaks off by itself once it gets long. Hold a finger down to - * take a drag — the coal flares, the tobacco burns faster and the sidestream - * plume is pulled thin — and let go for the surge of smoke that follows. A - * tap knocks the ash off: the column detaches, tumbles and falls. Once the - * coal reaches the filter it goes out, and a tap swaps in a fresh one. - * - * The smoke rises toward the real ceiling: `atan2(x, -y)` is the same - * in-plane gravity angle the lighter uses, blended by confidence² so a phone - * lying flat holds its last angle, and carried on a stiff spring so the plume - * bends with a twist instead of snapping. - * - * Writes the live channel every frame: - * `u.live = (ember 0..1, draw 0..1, puff 0..1, tilt rad)`, - * `u.liveData[0] = (turbulencePhase, burn 0..1, ash 0..1, puffAge 0..1)`, - * `u.liveData[1] = (flick 0..1, chunkAge 0..1, flare 0..1, visibility 0..1)`, - * `u.liveData[2] = (chunkLen 0..1, chunkKick -1..1, 0, 0)`. - * `burn`, `ash` and `chunkLen` are fractions of the tobacco's length. - */ -export function useCigarettePhysics(): { - paramsSynchronizable: ReturnType< - typeof useParamsSynchronizable - >['paramsSynchronizable']; - pressIn: () => void; - pressOut: () => void; -} { - const { paramsSynchronizable, setParamsSynchronizable } = - useParamsSynchronizable([0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0]); - - const simRef = useRef(null); - if (simRef.current === null) { - simRef.current = { - lit: true, - ember: 0, - burn: 0.04, - ash: 0.05, - pressing: false, - pressStart: 0, - draw: 0, - puff: 0, - puffAge: 1, - chunkLen: 0, - chunkKick: 0, - chunkAge: 1, - swapAge: 1, - flare: 1, - tiltTarget: 0, - tilt: 0, - tiltVel: 0, - phase: 0, - }; - } - - // Accelerometer -> in-plane gravity angle. Upright portrait reads y ~ -1, - // and a positive angle means the phone's right-hand side has dropped. - useEffect(() => { - let sub: { remove: () => void } | null = null; - let cancelled = false; - - (async () => { - try { - const ok = await Accelerometer.isAvailableAsync(); - if (!ok || cancelled) { - return; - } - Accelerometer.setUpdateInterval(16); - sub = Accelerometer.addListener(({ x, y }) => { - const s = simRef.current as CigaretteSim; - const reading = Math.atan2(x, -y); - const conf = Math.min(1, Math.hypot(x, y) / 0.35); - - // Weak in-plane signal (phone near-flat) HOLDS the last angle. - let delta = reading - s.tiltTarget; - if (delta > Math.PI) { - delta -= 2 * Math.PI; - } else if (delta < -Math.PI) { - delta += 2 * Math.PI; - } - s.tiltTarget += delta * conf * conf; - }); - } catch { - // No accelerometer (simulator) -> the smoke just rises straight up. - } - })(); - - // The simulator has no accelerometer and no patience: let a debugger - // feed the tilt, and skip ahead along the burn. - if (__DEV__) { - const g = globalThis as { - __cigTilt?: (rad: number) => void; - __cigBurn?: (burn: number) => void; - }; - g.__cigTilt = (rad: number) => { - (simRef.current as CigaretteSim).tiltTarget = rad; - }; - g.__cigBurn = (burn: number) => { - (simRef.current as CigaretteSim).burn = burn; - }; - } - - return () => { - cancelled = true; - if (sub) { - sub.remove(); - } - if (__DEV__) { - const g = globalThis as { __cigTilt?: unknown; __cigBurn?: unknown }; - delete g.__cigTilt; - delete g.__cigBurn; - } - }; - }, []); - - useEffect(() => { - let raf = 0; - let lastTs = 0; - - const step = (now: number) => { - const s = simRef.current as CigaretteSim; - const dt = lastTs === 0 ? 0.016 : Math.min(0.05, (now - lastTs) / 1000); - lastTs = now; - - s.tiltVel += (s.tiltTarget - s.tilt) * TILT_STIFFNESS * dt; - s.tiltVel -= s.tiltVel * TILT_DAMPING * dt; - s.tilt += s.tiltVel * dt; - - // Drag envelope: only once the press has outlived a tap. - const held = s.pressing ? (now - s.pressStart) / 1000 : 0; - const drawing = s.lit && s.pressing && held > DRAW_DELAY; - const drawRate = drawing ? DRAW_IN_RATE : DRAW_OUT_RATE; - s.draw += ((drawing ? 1 : 0) - s.draw) * Math.min(1, drawRate * dt); - - // The coal eats the tobacco; the ash is whatever it leaves behind. - if (s.lit) { - const dBurn = dt * (1 / SMOLDER_TIME + s.draw / DRAW_TIME); - s.burn = Math.min(1, s.burn + dBurn); - s.ash = Math.min(ASH_MAX, s.ash + dBurn); - if (s.ash >= ASH_MAX) { - // Too long to hold itself up: it breaks off, barely kicked. - dropAsh(s, Math.random() < 0.5 ? -0.12 : 0.12); - } - if (s.burn >= 1) { - s.lit = false; - } - } - const emberRate = s.lit ? EMBER_IN_RATE : EMBER_OUT_RATE; - s.ember += ((s.lit ? 1 : 0) - s.ember) * Math.min(1, emberRate * dt); - if (!s.lit && s.ember < 0.002) { - s.ember = 0; - } - - // Swapping in a fresh one: fade the butt out, reset, fade in. - if (s.swapAge < 1) { - const before = s.swapAge; - s.swapAge = Math.min(1, s.swapAge + dt / SWAP_DURATION); - if (before < SWAP_CROSS && s.swapAge >= SWAP_CROSS) { - s.lit = true; - s.burn = 0; - s.ash = 0; - s.chunkAge = 1; - s.flare = 1; - s.puffAge = 1; - } - } - const vis = - s.swapAge >= 1 - ? 1 - : s.swapAge < SWAP_CROSS - ? 1 - s.swapAge / SWAP_CROSS - : (s.swapAge - SWAP_CROSS) / (1 - SWAP_CROSS); - - s.puffAge = Math.min(1, s.puffAge + dt / PUFF_DURATION); - s.chunkAge = Math.min(1, s.chunkAge + dt / CHUNK_DURATION); - s.flare *= Math.exp(-dt / FLARE_TAU); - - const puff = s.puffAge >= 1 ? 0 : s.puff * Math.pow(1 - s.puffAge, 0.7); - const flick = s.chunkAge >= 1 ? 0 : 1; - - // Turbulence runs faster while air is being pulled through the tip. - s.phase += dt * (0.75 + 0.45 * s.draw + 0.35 * puff); - - setParamsSynchronizable( - s.ember, - s.draw, - puff, - s.tilt, - s.phase, - s.burn, - s.ash, - s.puffAge, - flick, - s.chunkAge, - s.flare, - vis, - s.chunkLen, - s.chunkKick, - 0, - 0 - ); - raf = requestAnimationFrame(step); - }; - - raf = requestAnimationFrame(step); - return () => cancelAnimationFrame(raf); - }, [setParamsSynchronizable]); - - const pressIn = useCallback(() => { - const s = simRef.current as CigaretteSim; - s.pressing = true; - s.pressStart = performance.now(); - }, []); - - const pressOut = useCallback(() => { - const s = simRef.current as CigaretteSim; - if (!s.pressing) { - return; - } - s.pressing = false; - const held = (performance.now() - s.pressStart) / 1000; - - if (held < TAP_MAX) { - if (!s.lit && s.swapAge >= 1) { - // Spent, or dying: swap in a fresh one. - s.swapAge = 0; - } else if (s.lit) { - dropAsh(s, Math.random() < 0.5 ? -1 : 1); - } - return; - } - - // Letting go after a real drag: the coal is at its hottest and the - // sidestream surges. - if (s.draw > 0.3) { - s.puff = s.draw; - s.puffAge = 0; - } - }, []); - - return { paramsSynchronizable, pressIn, pressOut }; -} - -/** Knock the ash column off. `kick` is the sideways impulse, -1..1. */ -function dropAsh(s: CigaretteSim, kick: number) { - if (s.ash < 0.015) { - return; - } - s.chunkLen = s.ash; - s.chunkKick = kick; - s.chunkAge = 0; - s.ash = 0; - // Freshly bared coal glows brighter for a moment. - s.flare = Math.max(s.flare, 0.45); -} diff --git a/example/src/hooks/useFerrofluidField.ts b/example/src/hooks/useFerrofluidField.ts new file mode 100644 index 0000000..e79c363 --- /dev/null +++ b/example/src/hooks/useFerrofluidField.ts @@ -0,0 +1,283 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Magnetometer } from 'expo-sensors'; +import { useParamsSynchronizable } from 'react-native-effects'; + +/** Where the puddle rests, centred coords (x scaled by aspect, y-up). */ +const REST = { x: 0, y: -0.02 }; +/** + * Field change (uT) ignored: Earth's ~50 uT swings by up to twice that in + * the device frame just from turning the phone. + */ +const FIELD_DEADZONE = 80; +/** Field change that saturates the spikes (uT); a neodymium magnet nearby. */ +const FIELD_FULL = 2000; +/** Knee of the log curve: smaller = more sensitive to a weak magnet. */ +const FIELD_KNEE = 120; +/** How far the magnet's direction pulls the gather point, centred units. */ +const FIELD_REACH = 0.32; +/** Samples averaged into the Earth-field baseline. */ +const CALIBRATION_SAMPLES = 20; +const SENSOR_INTERVAL_MS = 20; +/** Spikes snap up and ring as they settle: a light, underdamped spring. */ +const RISE_STIFFNESS = 110; +const RISE_DAMPING = 7.5; +/** The puddle itself is heavier: it creeps toward the magnet. */ +const FLOW_STIFFNESS = 26; +const FLOW_DAMPING = 7.5; +/** Farthest the puddle centre slides off its rest point. */ +const MAX_GATHER = 0.07; +/** A finger held down ramps up like a magnet being brought in (s). */ +const TOUCH_RAMP = 0.35; +const TOUCH_STRENGTH = 0.9; + +type Vec = { x: number; y: number }; + +type FerroSim = { + aspect: number; + /** Magnet position the field implies, centred coords. */ + magnet: Vec; + strengthTarget: number; + strength: number; + strengthVel: number; + center: Vec; + centerVel: Vec; + phase: number; + touch: { at: Vec; held: number } | null; + sensor: { magnet: Vec; strength: number }; + override: { magnet: Vec; strength: number } | null; + baseline: { x: number; y: number; z: number } | null; + calibration: { x: number; y: number; z: number; n: number }; +}; + +export type FerroSource = 'sensor' | 'touch' | 'none'; + +function fieldToStrength(deltaUT: number): number { + const d = Math.max(0, deltaUT - FIELD_DEADZONE); + return Math.min( + 1, + Math.log1p(d / FIELD_KNEE) / Math.log1p(FIELD_FULL / FIELD_KNEE) + ); +} + +function clampLength(v: Vec, max: number): Vec { + const len = Math.hypot(v.x, v.y); + if (len <= max || len === 0) { + return v; + } + return { x: (v.x / len) * max, y: (v.y / len) * max }; +} + +/** + * Turns a real magnet near the phone into a magnet under a puddle of + * ferrofluid. + * + * The magnetometer's raw field (uT) minus an Earth-field baseline gives the + * magnet's field at the sensor. Its size maps through a log curve to spike + * strength. Its in-plane direction says where the magnet is: under a dipole + * the in-plane field points away from the axis for one pole and toward it for + * the other, and the out-of-screen component tells the two apart, so the + * fluid gathers toward the magnet whichever face is held to the phone. + * Without a sensor (simulator) a held finger acts as the magnet. + * + * Writes `u.live = (centerX, centerY, strength, phase)` and + * `u.liveData[0] = (magnetX, magnetY, strengthVelocity, 0)`, centred coords. + */ +export function useFerrofluidField(width: number, height: number) { + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable([REST.x, REST.y, 0, 0, REST.x, REST.y, 0, 0]); + const [sensorAvailable, setSensorAvailable] = useState(null); + + const simRef = useRef(null); + if (simRef.current === null) { + simRef.current = { + aspect: width / height, + magnet: { ...REST }, + strengthTarget: 0, + strength: 0, + strengthVel: 0, + center: { ...REST }, + centerVel: { x: 0, y: 0 }, + phase: 0, + touch: null, + sensor: { magnet: { ...REST }, strength: 0 }, + override: null, + baseline: null, + calibration: { x: 0, y: 0, z: 0, n: 0 }, + }; + } + simRef.current.aspect = width / height; + + const calibrate = useCallback(() => { + const s = simRef.current as FerroSim; + s.baseline = null; + s.calibration = { x: 0, y: 0, z: 0, n: 0 }; + s.sensor = { magnet: { ...REST }, strength: 0 }; + }, []); + + useEffect(() => { + let sub: { remove: () => void } | null = null; + let cancelled = false; + + const onReading = ({ x, y, z }: { x: number; y: number; z: number }) => { + const s = simRef.current as FerroSim; + if (s.baseline === null) { + const c = s.calibration; + c.x += x; + c.y += y; + c.z += z; + c.n += 1; + if (c.n >= CALIBRATION_SAMPLES) { + s.baseline = { x: c.x / c.n, y: c.y / c.n, z: c.z / c.n }; + } + return; + } + const dx = x - s.baseline.x; + const dy = y - s.baseline.y; + const dz = z - s.baseline.z; + const mag = Math.hypot(dx, dy, dz); + const strength = fieldToStrength(mag); + const pole = dz >= 0 ? -1 : 1; + const inPlane = + mag > 1e-3 ? { x: dx / mag, y: dy / mag } : { x: 0, y: 0 }; + s.sensor = { + strength, + magnet: { + x: REST.x + pole * inPlane.x * FIELD_REACH, + y: REST.y + pole * inPlane.y * FIELD_REACH, + }, + }; + }; + + const start = async () => { + try { + const ok = await Magnetometer.isAvailableAsync(); + if (cancelled) { + return; + } + setSensorAvailable(ok); + if (!ok) { + return; + } + Magnetometer.setUpdateInterval(SENSOR_INTERVAL_MS); + sub = Magnetometer.addListener(onReading); + } catch { + if (!cancelled) { + setSensorAvailable(false); + } + } + }; + start(); + + // The simulator has no magnetometer. `__ferroMagnet(x, y, strength)` holds + // a magnet at screen fraction (x, y), y down, strength 0..1, until + // `__ferroMagnet(null)` hands control back to the sensor and touch. + if (__DEV__) { + const g = globalThis as { + __ferroMagnet?: ( + x: number | null, + y?: number, + strength?: number + ) => void; + }; + g.__ferroMagnet = (x, y = 0.5, strength = 1) => { + const s = simRef.current as FerroSim; + if (x === null) { + s.override = null; + return; + } + s.override = { + magnet: { x: (x - 0.5) * s.aspect, y: 0.5 - y }, + strength: Math.max(0, Math.min(1, strength)), + }; + }; + } + + return () => { + cancelled = true; + if (sub) { + sub.remove(); + } + if (__DEV__) { + delete (globalThis as { __ferroMagnet?: unknown }).__ferroMagnet; + } + }; + }, []); + + useEffect(() => { + let raf = 0; + let lastTs = 0; + + const step = (now: number) => { + const s = simRef.current as FerroSim; + const dt = lastTs === 0 ? 0.016 : Math.min(0.05, (now - lastTs) / 1000); + lastTs = now; + + if (s.touch) { + s.touch.held = Math.min(1, s.touch.held + dt / TOUCH_RAMP); + } + if (s.override) { + s.magnet = s.override.magnet; + s.strengthTarget = s.override.strength; + } else if (s.touch) { + s.magnet = s.touch.at; + s.strengthTarget = TOUCH_STRENGTH * s.touch.held; + } else { + s.magnet = s.sensor.magnet; + s.strengthTarget = s.sensor.strength; + } + + s.strengthVel += (s.strengthTarget - s.strength) * RISE_STIFFNESS * dt; + s.strengthVel -= s.strengthVel * RISE_DAMPING * dt; + s.strength = Math.max(0, s.strength + s.strengthVel * dt); + + const pull = clampLength( + { x: s.magnet.x - REST.x, y: s.magnet.y - REST.y }, + MAX_GATHER + ); + const gather = Math.min(1, s.strengthTarget * 1.2); + const goal = { x: REST.x + pull.x * gather, y: REST.y + pull.y * gather }; + s.centerVel.x += (goal.x - s.center.x) * FLOW_STIFFNESS * dt; + s.centerVel.y += (goal.y - s.center.y) * FLOW_STIFFNESS * dt; + s.centerVel.x -= s.centerVel.x * FLOW_DAMPING * dt; + s.centerVel.y -= s.centerVel.y * FLOW_DAMPING * dt; + s.center.x += s.centerVel.x * dt; + s.center.y += s.centerVel.y * dt; + + s.phase += dt * (0.6 + Math.abs(s.strengthVel) * 0.8); + + setParamsSynchronizable( + s.center.x, + s.center.y, + s.strength, + s.phase, + s.magnet.x, + s.magnet.y, + s.strengthVel, + 0 + ); + raf = requestAnimationFrame(step); + }; + + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [setParamsSynchronizable]); + + /** Finger as a magnet: screen points, y down. Pass null on release. */ + const setTouch = useCallback( + (point: { x: number; y: number } | null) => { + const s = simRef.current as FerroSim; + if (point === null) { + s.touch = null; + return; + } + const at = { + x: (point.x / width - 0.5) * s.aspect, + y: 0.5 - point.y / height, + }; + s.touch = { at, held: s.touch ? s.touch.held : 0 }; + }, + [width, height] + ); + + return { paramsSynchronizable, setTouch, calibrate, sensorAvailable }; +} diff --git a/example/src/hooks/useHoldTilt.ts b/example/src/hooks/useHoldTilt.ts new file mode 100644 index 0000000..5838b4c --- /dev/null +++ b/example/src/hooks/useHoldTilt.ts @@ -0,0 +1,98 @@ +import { useEffect, useRef } from 'react'; +import { DeviceMotion } from 'expo-sensors'; + +/** Radians of tilt away from the resting hold that reach the full -1..1. */ +const FULL_TILT_RAD = 0.45; +/** The resting hold drifts with the hand; follow it slowly (s). */ +const BASELINE_TAU = 2.5; +const SMOOTH_TAU = 0.08; + +type TiltState = { + x: number; + y: number; + baseBeta: number | null; + baseGamma: number; + rawX: number; + rawY: number; + override: { x: number; y: number } | null; +}; + +type DebugHook = (x: number | null, y?: number) => void; + +/** + * Tilt of the phone relative to how it is being held, as `x` (roll) and `y` + * (pitch) in -1..1, smoothed. The baseline follows the hand slowly, so the + * card rests flat however the phone is held and only the motion reads. + * In dev builds `globalThis[debugName](x, y)` overrides the sensor; `null` + * hands control back. + */ +export function useHoldTilt(debugName: string): React.RefObject { + const state = useRef({ + x: 0, + y: 0, + baseBeta: null, + baseGamma: 0, + rawX: 0, + rawY: 0, + override: null, + }); + + useEffect(() => { + const s = state.current; + let last = Date.now(); + DeviceMotion.setUpdateInterval(16); + const sub = DeviceMotion.addListener(({ rotation }) => { + if (!rotation) { + return; + } + const now = Date.now(); + const dt = Math.min(0.1, (now - last) / 1000); + last = now; + if (s.baseBeta === null) { + s.baseBeta = rotation.beta; + s.baseGamma = rotation.gamma; + } + const k = Math.min(1, dt / BASELINE_TAU); + s.baseBeta += (rotation.beta - s.baseBeta) * k; + s.baseGamma += (rotation.gamma - s.baseGamma) * k; + s.rawX = clampUnit((rotation.gamma - s.baseGamma) / FULL_TILT_RAD); + s.rawY = clampUnit((rotation.beta - s.baseBeta) / FULL_TILT_RAD); + }); + + if (__DEV__) { + const hook: DebugHook = (x, y = 0) => { + s.override = x === null ? null : { x, y }; + }; + (globalThis as Record)[debugName] = hook; + } + + let frame = 0; + let prev = Date.now(); + const step = () => { + const now = Date.now(); + const dt = Math.min(0.05, (now - prev) / 1000); + prev = now; + const tx = s.override ? s.override.x : s.rawX; + const ty = s.override ? s.override.y : s.rawY; + const k = Math.min(1, dt / SMOOTH_TAU); + s.x += (tx - s.x) * k; + s.y += (ty - s.y) * k; + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + + return () => { + cancelAnimationFrame(frame); + sub.remove(); + if (__DEV__) { + delete (globalThis as Record)[debugName]; + } + }; + }, [debugName]); + + return state; +} + +function clampUnit(v: number): number { + return Math.max(-1, Math.min(1, v)); +} diff --git a/example/src/hooks/useLavaLamp.ts b/example/src/hooks/useLavaLamp.ts new file mode 100644 index 0000000..797c89e --- /dev/null +++ b/example/src/hooks/useLavaLamp.ts @@ -0,0 +1,238 @@ +import { useEffect, useRef } from 'react'; +import { Accelerometer } from 'expo-sensors'; +import { useParamsSynchronizable } from 'react-native-effects'; + +/** + * The vessel, in the units the shader uses: photo pixels, Y up from the + * glass floor. `halfWidth` must match the shader's `glassHalfWidth`. + */ +export const GLASS_HEIGHT = 662; +function halfWidth(y: number): number { + const t = Math.min(1, Math.max(0, 1 - y / GLASS_HEIGHT)); + const side = 86 + 46 * (1 - (1 - t) * (1 - t)); + const k = Math.max(14 - y, 0); + return side - 60 + Math.sqrt(Math.max(3600 - k * k, 0)); +} + +/** Wax on the bulb warms quickly; out in the liquid it cools slowly (s). */ +const HEAT_TAU = 6; +const COOL_TAU = 45; +/** + * Wax in the pool clings to it until it is this hot, so it leaves the + * bottom hot enough to make it all the way up. + */ +const RELEASE_TEMP = 0.94; +/** Upward acceleration per unit of temperature above neutral (px/s^2). */ +const BUOYANCY = 85; +/** Paraffin in water is syrupy: velocity relaxes at this rate (1/s). */ +const DRAG = 1.6; +/** The heater warms only the bottom of the glass (px). */ +const HEATER_ZONE = 45; +const WALL_STIFFNESS = 30; +const TILT_RATE = 3; + +type Blob = { + x: number; + y: number; + vx: number; + vy: number; + r: number; + temp: number; + /** Held in the pool until it is hot enough to break free. */ + stuck: boolean; +}; + +type LavaSim = { + blobs: Blob[]; + /** Up, in the vessel's frame (unit vector, y up). */ + up: { x: number; y: number }; + upTarget: { x: number; y: number }; + tiltOverride: number | null; + phase: number; +}; + +const SEED_BLOBS: Array> = [ + { x: -15, y: 560, r: 58, temp: 0.5, vy: -3, stuck: false }, + { x: 20, y: 330, r: 52, temp: 0.8, vy: 14, stuck: false }, + { x: -30, y: 200, r: 40, temp: 0.45, vy: -8, stuck: false }, + { x: 45, y: 30, r: 56, temp: 0.75, vy: 0, stuck: true }, + { x: -50, y: 30, r: 48, temp: 0.5, vy: 0, stuck: true }, + { x: 25, y: 630, r: 36, temp: 0.49, vy: 0, stuck: false }, + { x: 50, y: 430, r: 32, temp: 0.47, vy: -6, stuck: false }, +]; + +/** Temperature the wax drifts toward at height y: hot on the bulb. */ +function ambientTemp(y: number, onBulb: boolean): number { + return onBulb ? 1.1 : 0.42 - 0.25 * (y / GLASS_HEIGHT); +} + +function stepBlob(b: Blob, s: LavaSim, dt: number) { + const onBulb = b.y < HEATER_ZONE + b.r * 0.2; + const tau = onBulb ? HEAT_TAU : COOL_TAU; + b.temp += (ambientTemp(b.y, onBulb) - b.temp) * (dt / tau); + if (onBulb && b.temp < 0.5) { + b.stuck = true; + } else if (b.stuck && b.temp > RELEASE_TEMP) { + b.stuck = false; + } + const buoyant = b.stuck ? Math.min(0, b.temp - 0.5) : b.temp - 0.5; + const lift = buoyant * BUOYANCY; + b.vx += s.up.x * lift * dt; + b.vy += s.up.y * lift * dt; + + const hw = halfWidth(b.y) - b.r * 0.75; + if (b.x > hw) { + b.vx -= (b.x - hw) * WALL_STIFFNESS * dt; + } else if (b.x < -hw) { + b.vx -= (b.x + hw) * WALL_STIFFNESS * dt; + } + const floor = b.r * 0.35; + const ceiling = GLASS_HEIGHT - b.r * 0.55; + if (b.y < floor) { + b.vy += (floor - b.y) * WALL_STIFFNESS * dt; + } else if (b.y > ceiling) { + b.vy -= (b.y - ceiling) * WALL_STIFFNESS * dt; + } + + const damp = Math.exp(-DRAG * dt); + b.vx *= damp; + b.vy *= damp; + b.x += b.vx * dt; + b.y += b.vy * dt; +} + +/** Blobs that overlap too far push apart gently, so they bump and pass. */ +function separate(blobs: Blob[], dt: number) { + for (let i = 0; i < blobs.length; i++) { + for (let j = i + 1; j < blobs.length; j++) { + const a = blobs[i] as Blob; + const b = blobs[j] as Blob; + const dx = b.x - a.x; + const dy = b.y - a.y; + const d = Math.hypot(dx, dy) || 1; + const minD = (a.r + b.r) * 0.7; + if (d < minD) { + const push = ((minD - d) / d) * 3 * dt; + a.vx -= dx * push; + a.vy -= dy * push; + b.vx += dx * push; + b.vy += dy * push; + } + } + } +} + +/** + * A lava lamp's wax, simulated. + * + * A handful of wax blobs, each with a temperature. The bulb heats the wax + * pooled on the bottom of the glass, which clings there until it is hot and + * then lets go. Warm wax is lighter than the liquid and rises, cools slowly + * up near the cap, turns heavy and sinks back to the pool to warm up again. Everything happens in syrup, so it takes tens of + * seconds. Buoyancy acts along real gravity from the accelerometer, so tilting + * the phone sends the wax drifting toward whichever side is now up. + * + * Writes `u.live = (phase, 0, 0, 0)` and one blob per `u.liveData[i]` as + * `(x, y, radius, stretch)` in photo pixels, y up from the glass floor. + */ +export function useLavaLamp() { + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable(new Array(4 + SEED_BLOBS.length * 4).fill(0)); + + const simRef = useRef(null); + if (simRef.current === null) { + simRef.current = { + blobs: SEED_BLOBS.map((b) => ({ ...b, vx: 0 })), + up: { x: 0, y: 1 }, + upTarget: { x: 0, y: 1 }, + tiltOverride: null, + phase: 0, + }; + } + + useEffect(() => { + let sub: { remove: () => void } | null = null; + let cancelled = false; + + const start = async () => { + try { + const ok = await Accelerometer.isAvailableAsync(); + if (!ok || cancelled) { + return; + } + Accelerometer.setUpdateInterval(50); + sub = Accelerometer.addListener(({ x, y }) => { + const s = simRef.current as LavaSim; + const len = Math.hypot(x, y); + if (s.tiltOverride !== null || len < 0.3) { + return; + } + s.upTarget = { x: -x / len, y: -y / len }; + }); + } catch { + // No accelerometer (simulator): the lamp stands upright. + } + }; + start(); + + // `__lavaTilt(rad)` tilts the phone by that angle (positive = right side + // down) until `__lavaTilt(null)` hands control back to the sensor. + if (__DEV__) { + const g = globalThis as { __lavaTilt?: (rad: number | null) => void }; + g.__lavaTilt = (rad) => { + const s = simRef.current as LavaSim; + s.tiltOverride = rad; + if (rad !== null) { + s.upTarget = { x: Math.sin(rad), y: Math.cos(rad) }; + } + }; + } + + return () => { + cancelled = true; + if (sub) { + sub.remove(); + } + if (__DEV__) { + delete (globalThis as { __lavaTilt?: unknown }).__lavaTilt; + } + }; + }, []); + + useEffect(() => { + let raf = 0; + let lastTs = 0; + + const step = (now: number) => { + const s = simRef.current as LavaSim; + const dt = lastTs === 0 ? 0.016 : Math.min(0.05, (now - lastTs) / 1000); + lastTs = now; + + const k = Math.min(1, TILT_RATE * dt); + s.up.x += (s.upTarget.x - s.up.x) * k; + s.up.y += (s.upTarget.y - s.up.y) * k; + const len = Math.hypot(s.up.x, s.up.y) || 1; + s.up.x /= len; + s.up.y /= len; + + for (const b of s.blobs) { + stepBlob(b, s, dt); + } + separate(s.blobs, dt); + s.phase += dt * 0.25; + + const out: number[] = [s.phase, 0, 0, 0]; + for (const b of s.blobs) { + const speed = Math.hypot(b.vx, b.vy); + out.push(b.x, b.y, b.r, Math.min(0.6, speed / 40)); + } + setParamsSynchronizable(...out); + raf = requestAnimationFrame(step); + }; + + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [setParamsSynchronizable]); + + return { paramsSynchronizable, blobCount: SEED_BLOBS.length }; +} diff --git a/example/src/hooks/usePolaroidShake.ts b/example/src/hooks/usePolaroidShake.ts new file mode 100644 index 0000000..a816fff --- /dev/null +++ b/example/src/hooks/usePolaroidShake.ts @@ -0,0 +1,108 @@ +import { useEffect, useRef } from 'react'; +import { Accelerometer } from 'expo-sensors'; +import { useSharedValue, type SharedValue } from 'react-native-reanimated'; + +/** Acceleration beyond gravity (g) that counts as a full shake. */ +const FULL_SHAKE_G = 1.2; +const SHAKE_ATTACK = 0.08; +const SHAKE_RELEASE = 0.9; +/** The print hangs from the fingers: a soft, ringing spring (deg). */ +const SWAY_STIFFNESS = 90; +const SWAY_DAMPING = 5; +const SWAY_KICK = 30; +/** Synthetic shake frequency while the debug override is set (Hz). */ +const OVERRIDE_HZ = 4; + +type ShakeSim = { + level: number; + lateral: number; + lateralPrev: number; + sway: number; + swayVel: number; + override: number | null; + overridePhase: number; +}; + +type DebugGlobal = { __polaroidShake?: (level: number | null) => void }; + +/** + * Shaking the phone like a Polaroid picture. + * + * Reads the accelerometer: anything beyond gravity is shake, run through an + * envelope into `level` (0..1), which the screen uses to speed development a + * little. Sideways jerks kick a soft spring that the print sways on (`sway`, + * degrees). `__polaroidShake(level)` overrides the sensor with a synthetic + * 4 Hz shake in dev builds; `null` hands control back. + */ +export function usePolaroidShake(): { + level: React.RefObject; + sway: SharedValue; +} { + const level = useRef(0); + const sway = useSharedValue(0); + const sim = useRef({ + level: 0, + lateral: 0, + lateralPrev: 0, + sway: 0, + swayVel: 0, + override: null, + overridePhase: 0, + }); + + useEffect(() => { + const s = sim.current; + let raw = 0; + Accelerometer.setUpdateInterval(16); + const sub = Accelerometer.addListener(({ x, y, z }) => { + raw = Math.abs(Math.sqrt(x * x + y * y + z * z) - 1); + s.lateral = x; + }); + + if (__DEV__) { + (globalThis as DebugGlobal).__polaroidShake = (v) => { + s.override = v; + }; + } + + let last = Date.now(); + let frame = 0; + const step = () => { + const now = Date.now(); + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + + let shake = Math.min(1, raw / FULL_SHAKE_G); + if (s.override !== null) { + s.overridePhase += dt * OVERRIDE_HZ * Math.PI * 2; + shake = s.override; + s.lateral = s.override * Math.sin(s.overridePhase); + } + const tau = shake > s.level ? SHAKE_ATTACK : SHAKE_RELEASE; + s.level += (shake - s.level) * Math.min(1, dt / tau); + + const jerk = (s.lateral - s.lateralPrev) / Math.max(dt, 1e-3); + s.lateralPrev = s.lateral; + const kick = Math.max(-40, Math.min(40, jerk)) * SWAY_KICK * 0.01; + s.swayVel += + (-SWAY_STIFFNESS * s.sway - SWAY_DAMPING * s.swayVel + kick * 60) * dt; + s.sway += s.swayVel * dt; + s.sway = Math.max(-9, Math.min(9, s.sway)); + + level.current = s.level; + sway.value = s.sway; + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + + return () => { + cancelAnimationFrame(frame); + sub.remove(); + if (__DEV__) { + delete (globalThis as DebugGlobal).__polaroidShake; + } + }; + }, [sway]); + + return { level, sway }; +} diff --git a/example/src/hooks/usePopSound.ts b/example/src/hooks/usePopSound.ts new file mode 100644 index 0000000..e6a343c --- /dev/null +++ b/example/src/hooks/usePopSound.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { AudioContext, type AudioBuffer } from 'react-native-audio-api'; + +const NOISE_SECONDS = 0.12; + +type Synth = { ctx: AudioContext; noise: AudioBuffer }; + +function createSynth(): Synth { + const ctx = new AudioContext(); + const length = Math.floor(ctx.sampleRate * NOISE_SECONDS); + const noise = ctx.createBuffer(1, length, ctx.sampleRate); + const data = new Float32Array(length); + for (let i = 0; i < length; i++) { + data[i] = Math.random() * 2 - 1; + } + noise.copyToChannel(data, 0); + return { ctx, noise }; +} + +function between(lo: number, hi: number): number { + return lo + Math.random() * (hi - lo); +} + +/** + * A synthesized bubble-wrap pop: a few milliseconds of band-passed noise for + * the snap of the film tearing, over a short falling sine for the thump of + * the air letting go. Pitch, brightness and level are drawn fresh for every + * pop so a run of them never sounds like one sample on repeat. + */ +export function usePopSound() { + const synthRef = useRef(null); + + useEffect(() => { + return () => { + synthRef.current?.ctx.close(); + synthRef.current = null; + }; + }, []); + + return useCallback((size = 1) => { + if (synthRef.current === null) { + synthRef.current = createSynth(); + } + const { ctx, noise } = synthRef.current; + const t = ctx.currentTime + 0.005; + const level = between(0.75, 1) * size; + + const snap = ctx.createBufferSource(); + snap.buffer = noise; + snap.playbackRate.value = between(0.8, 1.3); + const band = ctx.createBiquadFilter(); + band.type = 'bandpass'; + band.frequency.value = between(1600, 3400); + band.Q.value = 0.9; + const snapGain = ctx.createGain(); + snapGain.gain.setValueAtTime(level * 1.4, t); + snapGain.gain.exponentialRampToValueAtTime(0.001, t + between(0.035, 0.06)); + snap.connect(band); + band.connect(snapGain); + snapGain.connect(ctx.destination); + snap.start(t); + snap.stop(t + 0.1); + + const thump = ctx.createOscillator(); + thump.type = 'sine'; + const f0 = between(120, 190); + thump.frequency.setValueAtTime(f0, t); + thump.frequency.exponentialRampToValueAtTime(f0 * 0.4, t + 0.08); + const thumpGain = ctx.createGain(); + thumpGain.gain.setValueAtTime(level * 0.55, t); + thumpGain.gain.exponentialRampToValueAtTime(0.001, t + 0.09); + thump.connect(thumpGain); + thumpGain.connect(ctx.destination); + thump.start(t); + thump.stop(t + 0.12); + }, []); +} diff --git a/example/src/hooks/useScratchCard.ts b/example/src/hooks/useScratchCard.ts new file mode 100644 index 0000000..1930717 --- /dev/null +++ b/example/src/hooks/useScratchCard.ts @@ -0,0 +1,252 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Vibration } from 'react-native'; +import { useSharedValue } from 'react-native-reanimated'; +import { useParamsSynchronizable } from 'react-native-effects'; +import { + AREAS, + AREA_RADIUS, + CHANNEL_LENGTH, + GRID_X, + GRID_Y, + PLAY_H, + PLAY_W, + TILT_SLOT, +} from '../components/scratchoff/ScratchCoating'; +import { FLAKE_COUNT, FLAKE_STRIDE } from '../components/scratchoff/Flakes'; +import { setScratchSpeed } from '../components/scratchoff/scratchSound'; +import { useHoldTilt } from './useHoldTilt'; + +/** Finger contact radius (pt) and how much one pass removes. */ +const BRUSH_R = 17; +const BRUSH_STRENGTH = 0.7; +/** Fraction of an area's cells scratched through before it reveals itself. */ +const AUTO_REVEAL_AT = 0.6; +const REVEAL_SECONDS = 0.7; +const GRAVITY = 1500; +const FLAKE_LIFE = 1.1; + +const CELL_W = PLAY_W / GRID_X; +const CELL_H = PLAY_H / GRID_Y; + +type Flake = { + x: number; + y: number; + vx: number; + vy: number; + angle: number; + spin: number; + size: number; + life: number; +}; + +function insideArea(x: number, y: number, i: number): boolean { + const a = AREAS[i]!; + const r = AREA_RADIUS * 0.5; + return x > a.x + r && x < a.x + a.w - r && y > a.y + r && y < a.y + a.h - r; +} + +function cellsOf(i: number): number[] { + const cells: number[] = []; + for (let gy = 0; gy < GRID_Y; gy++) { + for (let gx = 0; gx < GRID_X; gx++) { + if (insideArea((gx + 0.5) * CELL_W, (gy + 0.5) * CELL_H, i)) { + cells.push(gy * GRID_X + gx); + } + } + } + return cells; +} + +const AREA_CELLS = AREAS.map((_, i) => cellsOf(i)); + +/** + * State of a scratch card: the coverage grid the shader cuts the latex + * with, each area's auto-reveal sweep, the tilt, and the falling flakes. + * + * `stroke(x, y, first)` takes the finger in play-area points. Each segment is + * stamped into the grid with a soft round brush, flakes are flicked off along + * it, and the scratch noise follows its speed. Once 60% of an area's cells are + * through, the area sweeps itself clean with a haptic tick. The channel is + * rewritten every frame by one loop: `u.live` = reveal per area, + * `u.liveData` = grid, then tilt. Tilt has a dev override, + * `__scratchTilt(x, y)` / `__scratchTilt(null)`. + */ +export function useScratchCard() { + const tilt = useHoldTilt('__scratchTilt'); + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable(new Array(CHANNEL_LENGTH).fill(0)); + const flakeState = useSharedValue( + new Array(FLAKE_COUNT * FLAKE_STRIDE).fill(0) + ); + const grid = useRef(new Float64Array(GRID_X * GRID_Y)); + const reveal = useRef([0, 0, 0, 0]); + const revealing = useRef([false, false, false, false]); + const flakes = useRef([]); + const last = useRef({ x: 0, y: 0, t: 0 }); + const [revealed, setRevealed] = useState(0); + + const stamp = useCallback((x: number, y: number) => { + const g = grid.current; + const x0 = Math.max(0, Math.floor((x - BRUSH_R) / CELL_W)); + const x1 = Math.min(GRID_X - 1, Math.floor((x + BRUSH_R) / CELL_W)); + const y0 = Math.max(0, Math.floor((y - BRUSH_R) / CELL_H)); + const y1 = Math.min(GRID_Y - 1, Math.floor((y + BRUSH_R) / CELL_H)); + for (let gy = y0; gy <= y1; gy++) { + for (let gx = x0; gx <= x1; gx++) { + const dx = (gx + 0.5) * CELL_W - x; + const dy = (gy + 0.5) * CELL_H - y; + const fall = 1 - Math.hypot(dx, dy) / BRUSH_R; + if (fall > 0) { + const i = gy * GRID_X + gx; + g[i] = Math.min(1, g[i]! + fall * BRUSH_STRENGTH); + } + } + } + }, []); + + const flick = useCallback((x: number, y: number, dx: number, dy: number) => { + const onCoat = AREAS.some((_, i) => insideArea(x, y, i)); + if (!onCoat || Math.random() > 0.55) { + return; + } + const list = flakes.current; + if (list.length >= FLAKE_COUNT) { + list.shift(); + } + list.push({ + x, + y, + vx: dx * 0.25 + (Math.random() - 0.5) * 140, + vy: dy * 0.25 - 60 - Math.random() * 120, + angle: Math.random() * 360, + spin: (Math.random() - 0.5) * 1400, + size: 1.5 + Math.random() * 3.5, + life: FLAKE_LIFE * (0.6 + Math.random() * 0.4), + }); + }, []); + + const checkAreas = useCallback(() => { + const g = grid.current; + AREA_CELLS.forEach((cells, i) => { + if (revealing.current[i]) { + return; + } + const through = cells.filter((c) => g[c]! > 0.5).length; + if (through >= cells.length * AUTO_REVEAL_AT) { + revealing.current[i] = true; + Vibration.vibrate(20); + if (i < 3) { + setRevealed((n) => n + 1); + } + } + }); + }, []); + + const stroke = useCallback( + (x: number, y: number, first: boolean) => { + const now = Date.now(); + const prev = last.current; + last.current = { x, y, t: now }; + if (first) { + stamp(x, y); + return; + } + const dx = x - prev.x; + const dy = y - prev.y; + const dist = Math.hypot(dx, dy); + const steps = Math.max(1, Math.ceil(dist / 4)); + for (let s = 1; s <= steps; s++) { + stamp(prev.x + (dx * s) / steps, prev.y + (dy * s) / steps); + } + const dt = Math.max(0.008, (now - prev.t) / 1000); + const speed = dist / dt; + setScratchSpeed(speed); + const vx = dx / dt; + const vy = dy / dt; + for (let k = 0; k < Math.min(4, steps); k++) { + flick(x - (dx * k) / steps, y - (dy * k) / steps, vx, vy); + } + checkAreas(); + }, + [checkAreas, flick, stamp] + ); + + const lift = useCallback(() => { + setScratchSpeed(0); + }, []); + + const reset = useCallback(() => { + grid.current.fill(0); + reveal.current = [0, 0, 0, 0]; + revealing.current = [false, false, false, false]; + flakes.current = []; + setRevealed(0); + }, []); + + useEffect(() => { + const channel = new Array(CHANNEL_LENGTH).fill(0); + const flakeOut = new Array(FLAKE_COUNT * FLAKE_STRIDE).fill(0); + let prev = Date.now(); + let frame = 0; + let hadFlakes = false; + const step = () => { + const now = Date.now(); + const dt = Math.min(0.05, (now - prev) / 1000); + prev = now; + + for (let i = 0; i < 4; i++) { + if (revealing.current[i]) { + reveal.current[i] = Math.min( + 1, + reveal.current[i]! + dt / REVEAL_SECONDS + ); + } + channel[i] = reveal.current[i]; + } + const g = grid.current; + for (let i = 0; i < g.length; i++) { + channel[4 + i] = g[i]; + } + channel[4 + TILT_SLOT * 4] = tilt.current.x; + channel[5 + TILT_SLOT * 4] = tilt.current.y; + setParamsSynchronizable(...channel); + + const list = flakes.current; + for (const f of list) { + f.vy += GRAVITY * dt; + f.x += f.vx * dt; + f.y += f.vy * dt; + f.angle += f.spin * dt; + f.life -= dt; + } + flakes.current = list.filter((f) => f.life > 0); + if (flakes.current.length === 0 && !hadFlakes) { + frame = requestAnimationFrame(step); + return; + } + hadFlakes = flakes.current.length > 0; + flakeOut.fill(0); + flakes.current.forEach((f, i) => { + const o = i * FLAKE_STRIDE; + flakeOut[o] = f.x; + flakeOut[o + 1] = f.y; + flakeOut[o + 2] = f.angle; + flakeOut[o + 3] = f.size; + flakeOut[o + 4] = f.life / FLAKE_LIFE; + }); + flakeState.value = flakeOut.slice(); + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + return () => cancelAnimationFrame(frame); + }, [flakeState, setParamsSynchronizable, tilt]); + + return { + paramsSynchronizable, + flakeState, + stroke, + lift, + reset, + revealed, + }; +} diff --git a/example/src/hooks/useTorchAim.ts b/example/src/hooks/useTorchAim.ts new file mode 100644 index 0000000..ff1322b --- /dev/null +++ b/example/src/hooks/useTorchAim.ts @@ -0,0 +1,70 @@ +import { useEffect, useRef } from 'react'; +import { DeviceMotion } from 'expo-sensors'; + +/** Radians of tilt that swing the spot from the centre to the screen edge. */ +const REACH_RAD = 0.42; + +type Aim = { x: number; y: number }; + +function clampUnit(v: number) { + return Math.max(-1, Math.min(1, v)); +} + +/** + * Where the phone points, as `{ x, y }` in -1..1 (0 = the pose the phone had + * when the screen opened). Read the returned ref every frame. + * + * `__torchAim(x, y)` holds the aim (the simulator has no motion sensor); + * `__torchAim(null)` hands it back to the sensor. + */ +export function useTorchAim() { + const aim = useRef({ x: 0, y: 0 }); + const override = useRef(null); + + useEffect(() => { + let neutral: { beta: number; gamma: number } | null = null; + let sub: { remove: () => void } | null = null; + let cancelled = false; + + const listen = async () => { + const available = await DeviceMotion.isAvailableAsync().catch( + () => false + ); + if (!available || cancelled) return; + DeviceMotion.setUpdateInterval(16); + sub = DeviceMotion.addListener(({ rotation }) => { + if (!rotation || override.current) return; + neutral ??= { beta: rotation.beta, gamma: rotation.gamma }; + aim.current = { + x: clampUnit((rotation.gamma - neutral.gamma) / REACH_RAD), + y: clampUnit(-(rotation.beta - neutral.beta) / REACH_RAD), + }; + }); + }; + listen(); + + if (__DEV__) { + const g = globalThis as { + __torchAim?: (x: number | null, y?: number) => void; + }; + g.__torchAim = (x, y = 0) => { + if (x === null) { + override.current = null; + return; + } + override.current = { x: clampUnit(x), y: clampUnit(y) }; + aim.current = override.current; + }; + } + + return () => { + cancelled = true; + sub?.remove(); + if (__DEV__) { + delete (globalThis as { __torchAim?: unknown }).__torchAim; + } + }; + }, []); + + return aim; +} diff --git a/example/src/screens/AboutScreen.tsx b/example/src/screens/AboutScreen.tsx new file mode 100644 index 0000000..d424e47 --- /dev/null +++ b/example/src/screens/AboutScreen.tsx @@ -0,0 +1,156 @@ +import { + Image, + Linking, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import appConfig from '../../app.json'; +import { Chevron } from '../components/catalog/Glyphs'; +import { colors, SCREEN_MARGIN, SERIF } from '../catalog/theme'; + +const REPOSITORY_URL = 'https://github.com/blazejkustra/react-native-effects'; + +const LINKS = [ + { + title: 'Privacy Policy', + url: `${REPOSITORY_URL}/blob/main/docs/privacy-policy.md`, + }, + { + title: 'Credits and Licenses', + url: `${REPOSITORY_URL}/blob/main/docs/credits.md`, + }, + { title: 'Source Code', url: REPOSITORY_URL }, +]; + +function LinkRow({ + title, + url, + isLast, +}: { + title: string; + url: string; + isLast: boolean; +}) { + return ( + Linking.openURL(url)} + style={({ pressed }) => [styles.link, pressed && styles.linkPressed]} + > + + {title} + + + + ); +} + +export default function AboutScreen() { + const insets = useSafeAreaInsets(); + + return ( + + + + {appConfig.expo.name} + + Version {appConfig.expo.version} + + Shaders you can tilt, blow on, shake and break. Every effect renders + live on your iPhone's GPU, built with the open-source + react-native-effects library. + + + {LINKS.map((link, index) => ( + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.sheet, + }, + content: { + alignItems: 'center', + paddingTop: 40, + paddingHorizontal: SCREEN_MARGIN, + }, + icon: { + width: 88, + height: 88, + borderRadius: 20, + }, + name: { + marginTop: 16, + fontFamily: SERIF, + fontSize: 30, + fontWeight: '600', + color: colors.text, + }, + version: { + marginTop: 4, + fontSize: 15, + color: colors.secondary, + }, + description: { + marginTop: 20, + fontSize: 16, + lineHeight: 23, + textAlign: 'center', + color: colors.secondary, + }, + links: { + alignSelf: 'stretch', + marginTop: 28, + borderRadius: 14, + overflow: 'hidden', + backgroundColor: colors.surface, + }, + link: { + paddingLeft: 16, + }, + linkPressed: { + backgroundColor: 'rgba(255, 255, 255, 0.08)', + }, + linkBody: { + minHeight: 50, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 12, + paddingRight: 16, + }, + linkSeparator: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.separator, + }, + linkTitle: { + flex: 1, + fontSize: 17, + color: colors.text, + }, +}); diff --git a/example/src/screens/AiOrbScreen.tsx b/example/src/screens/AiOrbScreen.tsx index ff08379..1c6df29 100644 --- a/example/src/screens/AiOrbScreen.tsx +++ b/example/src/screens/AiOrbScreen.tsx @@ -1,8 +1,8 @@ +import { MicGlyph } from '../components/catalog/Glyphs'; import { View, Text, StyleSheet, StatusBar, Pressable } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import ThinkingOrb from '../components/ThinkingOrb'; import { useAudioReactive } from '../hooks/useAudioReactive'; -import { BackButton } from '../components/BackButton'; export default function AiOrbScreen() { const insets = useSafeAreaInsets(); @@ -30,19 +30,12 @@ export default function AiOrbScreen() { style={[styles.mic, listening && styles.micOn]} onPress={toggle} > - 🎙️ + - {listening ? 'Speak — the orb reacts' : 'Tap to talk to it'} + {listening ? 'Speak and the orb reacts' : 'Tap to talk to it'} - - - - ); } @@ -52,11 +45,6 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#06070a', }, - backWrap: { - position: 'absolute', - left: 22, - zIndex: 100, - }, center: { flex: 1, alignItems: 'center', @@ -98,9 +86,6 @@ const styles = StyleSheet.create({ backgroundColor: '#2a1f4d', borderColor: '#6b4bd6', }, - micGlyph: { - fontSize: 26, - }, hint: { color: '#8a8a99', fontSize: 14, diff --git a/example/src/screens/AiShimmerScreen.tsx b/example/src/screens/AiShimmerScreen.tsx index 42df491..924c862 100644 --- a/example/src/screens/AiShimmerScreen.tsx +++ b/example/src/screens/AiShimmerScreen.tsx @@ -10,12 +10,11 @@ import { } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import ShimmerSweep from '../components/ShimmerSweep'; -import { BackButton } from '../components/BackButton'; const THINK_MS = 2600; const ANSWER = - 'react-native-effects renders procedural WGSL shaders on WebGPU — off the JS thread, at 60fps. Aurora, liquid chrome, holographic foil, a card that snaps to dust… all generated in real time, no images or video. Just drop in a .'; + 'react-native-effects renders procedural WGSL shaders on WebGPU, off the JS thread, at 60fps. Aurora, liquid chrome, holographic foil, a card that snaps to dust… all generated in real time, no images or video. Just drop in a .'; /** Grey placeholder lines that the iridescent shimmer sweeps across. */ function Skeleton() { @@ -55,10 +54,9 @@ export default function AiShimmerScreen() { - Assistant - {thinking ? 'Generating…' : 'react-native-effects ✦'} + {thinking ? 'Generating…' : 'react-native-effects'} diff --git a/example/src/screens/AiVoiceScreen.tsx b/example/src/screens/AiVoiceScreen.tsx index 57ae1bf..b882aed 100644 --- a/example/src/screens/AiVoiceScreen.tsx +++ b/example/src/screens/AiVoiceScreen.tsx @@ -1,8 +1,8 @@ +import { MicGlyph } from '../components/catalog/Glyphs'; import { View, Text, StyleSheet, StatusBar, Pressable } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import VoiceWave from '../components/VoiceWave'; import { useAudioReactive } from '../hooks/useAudioReactive'; -import { BackButton } from '../components/BackButton'; export default function AiVoiceScreen() { const insets = useSafeAreaInsets(); @@ -13,7 +13,6 @@ export default function AiVoiceScreen() { - Voice {listening ? 'Listening…' : 'Tap to talk'} @@ -34,19 +33,12 @@ export default function AiVoiceScreen() { style={[styles.mic, listening && styles.micOn]} onPress={toggle} > - 🎙️ + {listening ? 'Tap to stop' : 'Tap to speak'} - - - - ); } @@ -56,22 +48,9 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#06070a', }, - backWrap: { - position: 'absolute', - left: 22, - zIndex: 100, - }, header: { paddingHorizontal: 26, }, - kicker: { - color: '#7c7c8a', - fontSize: 14, - fontWeight: '700', - letterSpacing: 1, - textTransform: 'uppercase', - marginBottom: 8, - }, title: { color: '#fff', fontSize: 30, @@ -110,9 +89,6 @@ const styles = StyleSheet.create({ backgroundColor: '#2a1f4d', borderColor: '#6b4bd6', }, - micGlyph: { - fontSize: 26, - }, hint: { color: '#8a8a99', fontSize: 14, diff --git a/example/src/screens/AuroraScreen.tsx b/example/src/screens/AuroraScreen.tsx index 2384afb..8c75212 100644 --- a/example/src/screens/AuroraScreen.tsx +++ b/example/src/screens/AuroraScreen.tsx @@ -1,24 +1,11 @@ -import { View, Text, StyleSheet, StatusBar } from 'react-native'; +import { StatusBar, StyleSheet, View } from 'react-native'; import { Aurora } from 'react-native-effects'; -import { Header } from '../components/Header'; export default function AuroraScreen() { return ( - - - -
- - - - Aurora Borealis - - Flowing curtains of light with shimmering colors - - - + ); } @@ -28,32 +15,4 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#000', }, - content: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 40, - }, - infoCard: { - backgroundColor: 'rgba(0, 0, 0, 0.4)', - borderRadius: 24, - padding: 32, - alignItems: 'center', - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.2)', - }, - title: { - fontSize: 32, - fontWeight: '800', - color: '#fff', - marginBottom: 12, - textAlign: 'center', - letterSpacing: -0.5, - }, - subtitle: { - fontSize: 16, - color: 'rgba(255, 255, 255, 0.8)', - textAlign: 'center', - fontWeight: '400', - }, }); diff --git a/example/src/screens/BannersScreen.tsx b/example/src/screens/BannersScreen.tsx index e3dfc64..dc1a533 100644 --- a/example/src/screens/BannersScreen.tsx +++ b/example/src/screens/BannersScreen.tsx @@ -5,7 +5,6 @@ import PartyConfetti from '../components/PartyConfetti'; import StreakFire from '../components/StreakFire'; import CoinGeyser from '../components/CoinGeyser'; import Thunderstorm from '../components/Thunderstorm'; -import { BackButton } from '../components/BackButton'; export default function BannersScreen() { const insets = useSafeAreaInsets(); @@ -28,13 +27,6 @@ export default function BannersScreen() { - - - - ); } @@ -50,9 +42,4 @@ const styles = StyleSheet.create({ banner: { marginTop: 14, }, - backWrap: { - position: 'absolute', - left: 22, - zIndex: 100, - }, }); diff --git a/example/src/screens/BubbleWrapScreen.tsx b/example/src/screens/BubbleWrapScreen.tsx new file mode 100644 index 0000000..8f3c247 --- /dev/null +++ b/example/src/screens/BubbleWrapScreen.tsx @@ -0,0 +1,119 @@ +import { useCallback, useMemo, useState } from 'react'; +import { + type GestureResponderEvent, + Platform, + Pressable, + StatusBar, + StyleSheet, + Text, + useWindowDimensions, + Vibration, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import BubbleWrap from '../components/bubblewrap/BubbleWrap'; +import { useBubbleWrap } from '../hooks/useBubbleWrap'; +import { usePopSound } from '../hooks/usePopSound'; + +/** + * Photo: "Oberfläche Wohnzimmertisch" by Martin Lorenz, CC BY-SA 3.0, via + * Wikimedia Commons + * (https://commons.wikimedia.org/wiki/File:Oberfl%C3%A4che_Wohnzimmertisch.JPG), + * rotated to portrait. + */ +const WALNUT = require('../../assets/bubblewrap/walnut.jpg'); +const IMG_W = 853; +const IMG_H = 1280; + +/** + * A sheet of bubble wrap on a walnut table. + * + * Press a bubble and it pops: a snap and a thump synthesized on the spot, + * never the same twice, and the dome crumples flat. Drag a finger (or + * several) across the sheet to pop everything it rolls over. + */ +export default function BubbleWrapScreen() { + const { width, height } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const [popped, setPopped] = useState(0); + const playPop = usePopSound(); + + const onPop = useCallback(() => { + playPop(); + if (Platform.OS === 'android') { + Vibration.vibrate(12); + } + setPopped((n) => n + 1); + }, [playPop]); + + const { paramsSynchronizable, grid, dragTo, releaseTouches, reset } = + useBubbleWrap(width, height, onPop); + + const coverScale = useMemo<[number, number]>(() => { + const screenAspect = width / height; + const imgAspect = IMG_W / IMG_H; + return screenAspect < imgAspect + ? [screenAspect / imgAspect, 1] + : [1, imgAspect / screenAspect]; + }, [width, height]); + + const onTouches = (e: GestureResponderEvent) => { + for (const t of e.nativeEvent.touches) { + dragTo(String(t.identifier), t.pageX, t.pageY); + } + }; + + const onNewSheet = () => { + reset(); + setPopped(0); + }; + + return ( + + + + + {popped >= 12 ? ( + + New sheet + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#3a2614', + }, + newSheet: { + position: 'absolute', + alignSelf: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 999, + backgroundColor: 'rgba(20, 12, 6, 0.55)', + }, + newSheetText: { + color: 'rgba(255, 244, 230, 0.85)', + fontSize: 14, + fontWeight: '600', + }, +}); diff --git a/example/src/screens/CalicoSwirlScreen.tsx b/example/src/screens/CalicoSwirlScreen.tsx index f62ba01..057df71 100644 --- a/example/src/screens/CalicoSwirlScreen.tsx +++ b/example/src/screens/CalicoSwirlScreen.tsx @@ -1,28 +1,11 @@ -import { View, Text, StyleSheet, StatusBar } from 'react-native'; +import { StatusBar, StyleSheet, View } from 'react-native'; import { CalicoSwirl } from 'react-native-effects'; -import { Header } from '../components/Header'; export default function CalicoSwirlScreen() { return ( - - - -
- - - - Calico Swirl - - A warped noise pattern with flowing colors and lighting - - - + ); } @@ -32,35 +15,4 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#000', }, - content: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 40, - }, - infoCard: { - backgroundColor: 'rgba(0, 0, 0, 0.4)', - borderRadius: 24, - padding: 32, - alignItems: 'center', - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.2)', - }, - title: { - fontSize: 32, - fontWeight: '800', - color: '#fff', - marginBottom: 12, - textAlign: 'center', - letterSpacing: -0.5, - }, - subtitle: { - fontSize: 16, - color: 'rgba(255, 255, 255, 0.8)', - textAlign: 'center', - fontWeight: '400', - }, - calicoSwirl: { - width: '100%', - }, }); diff --git a/example/src/screens/CampfireScreen.tsx b/example/src/screens/CampfireScreen.tsx index bc19987..4e3cc90 100644 --- a/example/src/screens/CampfireScreen.tsx +++ b/example/src/screens/CampfireScreen.tsx @@ -1,27 +1,11 @@ -import { View, Text, StyleSheet, StatusBar } from 'react-native'; +import { StatusBar, StyleSheet, View } from 'react-native'; import { Campfire } from 'react-native-effects'; -import { Header } from '../components/Header'; export default function CampfireScreen() { return ( - -
- - - - - Campfire - - Realistic fire effect with sparks and smoke - - - + ); } @@ -31,37 +15,11 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#000', }, - content: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 40, - position: 'relative', - }, - infoCard: { - backgroundColor: 'rgba(0, 0, 0, 0.4)', - borderRadius: 24, - padding: 32, - alignItems: 'center', - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.2)', - }, - title: { - fontSize: 32, - fontWeight: '800', - color: '#fff', - marginBottom: 12, - textAlign: 'center', - letterSpacing: -0.5, - }, - subtitle: { - fontSize: 16, - color: 'rgba(255, 255, 255, 0.8)', - textAlign: 'center', - fontWeight: '400', - }, - campfire: { - height: 200, - width: '100%', + fire: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + aspectRatio: 0.8, }, }); diff --git a/example/src/screens/CigaretteScreen.tsx b/example/src/screens/CigaretteScreen.tsx deleted file mode 100644 index db8d35d..0000000 --- a/example/src/screens/CigaretteScreen.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useMemo } from 'react'; -import { - Pressable, - StatusBar, - StyleSheet, - Text, - useWindowDimensions, -} from 'react-native'; -import Cigarette, { - CIG_ASPECT, - FILTER_FRAC, - HEADROOM, - PANE_SCALE, -} from '../components/cigarette/Cigarette'; -import Smoke from '../components/cigarette/Smoke'; -import { useCigarettePhysics } from '../hooks/useCigarettePhysics'; - -/** The cigarette's width on screen, in points. */ -const CIG_W = 68; -/** Where the unburnt tip sits, as a fraction of the screen's height from the top. */ -const TIP_FRAC = 0.55; - -/** - * A cigarette smouldering in a dark room. - * - * Hold a finger anywhere to take a drag and let go to exhale; tap to flick - * the ash. It burns down on its own, drops its ash when it gets long and - * goes out at the filter — tap to light another. Tilt the phone and the - * smoke keeps rising toward the real ceiling. No chrome: back is the native - * edge swipe. - */ -export default function CigaretteScreen() { - const { width, height } = useWindowDimensions(); - const { paramsSynchronizable, pressIn, pressOut } = useCigarettePhysics(); - - const cigL = CIG_W * CIG_ASPECT; - const viewH = cigL * HEADROOM; - const tipTop = Math.round(height * TIP_FRAC); - const layout = useMemo( - () => ({ - left: Math.round(width / 2 - (CIG_W * PANE_SCALE) / 2), - top: tipTop - (viewH - cigL), - width: CIG_W * PANE_SCALE, - height: viewH, - }), - [width, tipTop, viewH, cigL] - ); - - const smokeParams = useMemo( - () => [ - 0.5, - 1 - tipTop / height, - CIG_W / height, - (cigL * (1 - FILTER_FRAC)) / height, - ], - [tipTop, height, cigL] - ); - - return ( - - - ); -} - -const styles = StyleSheet.create({ - root: { - flex: 1, - backgroundColor: '#000', - }, - cigarette: { - position: 'absolute', - }, - hint: { - position: 'absolute', - top: 64, - left: 0, - right: 0, - textAlign: 'center', - color: 'rgba(255,255,255,0.28)', - fontSize: 13, - letterSpacing: 0.4, - }, -}); diff --git a/example/src/screens/CircularGradientScreen.tsx b/example/src/screens/CircularGradientScreen.tsx index 5ccf09f..c807b21 100644 --- a/example/src/screens/CircularGradientScreen.tsx +++ b/example/src/screens/CircularGradientScreen.tsx @@ -7,7 +7,6 @@ import { Dimensions, } from 'react-native'; import { CircularGradient } from 'react-native-effects'; -import { Header } from '../components/Header'; const { width } = Dimensions.get('window'); const CARD_WIDTH = width - 80; @@ -73,11 +72,6 @@ export default function CircularGradientScreen() { -
- ))} - - - - All gradients rendered with WebGPU - - ); @@ -142,7 +130,7 @@ const styles = StyleSheet.create({ }, scrollContent: { padding: 40, - paddingTop: 40, + paddingTop: 16, paddingBottom: 80, alignItems: 'center', }, @@ -154,14 +142,6 @@ const styles = StyleSheet.create({ overflow: 'hidden', borderWidth: 1, borderColor: '#252525', - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 8, - }, - shadowOpacity: 0.5, - shadowRadius: 16, - elevation: 10, }, gradientWrapper: { width: '100%', @@ -206,14 +186,4 @@ const styles = StyleSheet.create({ color: '#aaa', fontWeight: '500', }, - footer: { - marginTop: 20, - paddingVertical: 20, - }, - footerText: { - fontSize: 14, - color: '#555', - textAlign: 'center', - fontWeight: '500', - }, }); diff --git a/example/src/screens/CrackedScreenScreen.tsx b/example/src/screens/CrackedScreenScreen.tsx new file mode 100644 index 0000000..67aa796 --- /dev/null +++ b/example/src/screens/CrackedScreenScreen.tsx @@ -0,0 +1,78 @@ +import { type ComponentRef, useMemo, useRef } from 'react'; +import { StatusBar, StyleSheet, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import CrackedGlass from '../components/cracks/CrackedGlass'; +import LockScreen from '../components/cracks/LockScreen'; +import { useCrackedGlass } from '../components/cracks/useCrackedGlass'; +import { useSnapshotTexture } from '../components/cracks/useSnapshotTexture'; + +/** + * Wallpaper: "Seaside swing at dusk" by Georg Nietsch (Unsplash), CC0, via + * Wikimedia Commons + * (https://commons.wikimedia.org/wiki/File:Seaside_swing_at_dusk_(Unsplash).jpg). + */ +const WALLPAPER = require('../../assets/cracks/swing.jpg'); +const NOTIFICATION = { + title: 'Mom', + body: 'Did you drop your phone again? Your screen looked weird on FaceTime', +}; + +/** + * Tap anywhere and the cover glass breaks where you hit it. Long press to put + * a fresh screen on. + */ +export default function CrackedScreenScreen() { + const lockRef = useRef>(null); + const { uri, schedule } = useSnapshotTexture(lockRef); + const { paramsSynchronizable, hit, reset } = useCrackedGlass(); + + const gesture = useMemo(() => { + const tap = Gesture.Tap() + .runOnJS(true) + .maxDuration(400) + .onEnd((e, success) => { + if (success) { + hit(e.x, e.y); + } + }); + const hold = Gesture.LongPress() + .runOnJS(true) + .minDuration(650) + .onStart(reset); + return Gesture.Exclusive(hold, tap); + }, [hit, reset]); + + return ( + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, + fill: { + ...StyleSheet.absoluteFill, + }, +}); diff --git a/example/src/screens/CrtOffScreen.tsx b/example/src/screens/CrtOffScreen.tsx new file mode 100644 index 0000000..2231054 --- /dev/null +++ b/example/src/screens/CrtOffScreen.tsx @@ -0,0 +1,128 @@ +import { + type ComponentRef, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { Pressable, StatusBar, StyleSheet, View } from 'react-native'; +import { useParamsSynchronizable } from 'react-native-effects'; +import CrtTube from '../components/crt/CrtTube'; +import { playPowerOff, playPowerOn } from '../components/crt/tubeSound'; +import { useSnapshotTexture } from '../components/cracks/useSnapshotTexture'; +import HomeScreen from './HomeScreen'; + +/** Lets the tube's first texture upload finish before the splash plays. */ +const POWER_ON_DELAY_MS = 900; +/** Seconds after a toggle during which the tube is still animating. */ +const SETTLE_S = 2.2; + +/** + * The app itself switching off like an old CRT television. Its real home + * list is snapshotted with react-native-view-shot and drawn by CrtTube, which + * powers it on like a splash when the screen opens. Tap and the picture + * collapses into a white-hot line, then a dot that fades on its phosphor; tap + * again to power it back on with a degauss wobble. + */ +export default function CrtOffScreen() { + const contentRef = useRef>(null); + const { uri: snapshot, schedule } = useSnapshotTexture(contentRef); + const [isOff, setIsOff] = useState(true); + const toggledAt = useRef(0); + const frame = useRef(null); + const splashTimer = useRef | null>(null); + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable([1, SETTLE_S, 0, 0]); + + useEffect(() => { + return () => { + if (frame.current !== null) cancelAnimationFrame(frame.current); + }; + }, []); + + const run = useCallback( + (off: boolean) => { + const tick = () => { + const elapsed = (performance.now() - toggledAt.current) / 1000; + setParamsSynchronizable(off ? 1 : 0, Math.min(elapsed, SETTLE_S), 0, 0); + frame.current = elapsed < SETTLE_S ? requestAnimationFrame(tick) : null; + }; + if (frame.current !== null) cancelAnimationFrame(frame.current); + toggledAt.current = performance.now(); + tick(); + }, + [setParamsSynchronizable] + ); + + useEffect(() => { + if (!snapshot) return; + const id = setTimeout(() => { + setIsOff(false); + run(false); + }, POWER_ON_DELAY_MS); + splashTimer.current = id; + return () => clearTimeout(id); + }, [snapshot, run]); + + const toggle = useCallback(() => { + if (splashTimer.current !== null) clearTimeout(splashTimer.current); + const next = !isOff; + setIsOff(next); + run(next); + if (next) playPowerOff(); + else playPowerOn(); + }, [isOff, run]); + + // `__crtFreeze(off, seconds)` holds the tube at one moment of a power + // transition for inspection; the next tap resumes normal playback. + useEffect(() => { + if (!__DEV__) return; + const g = globalThis as { + __crtFreeze?: (off: boolean, seconds: number) => void; + }; + g.__crtFreeze = (off, seconds) => { + if (frame.current !== null) cancelAnimationFrame(frame.current); + frame.current = null; + setIsOff(off); + setParamsSynchronizable(off ? 1 : 0, seconds, 0, 0); + }; + return () => { + delete g.__crtFreeze; + }; + }, [setParamsSynchronizable]); + + return ( + + + + + + + {snapshot && ( + + )} + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, +}); diff --git a/example/src/screens/DandelionScreen.tsx b/example/src/screens/DandelionScreen.tsx index 976e2b1..cc280bd 100644 --- a/example/src/screens/DandelionScreen.tsx +++ b/example/src/screens/DandelionScreen.tsx @@ -7,7 +7,6 @@ import { useWindowDimensions, View, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Dandelion from '../components/dandelion/Dandelion'; import { useAudioReactive } from '../hooks/useAudioReactive'; @@ -47,7 +46,6 @@ const MIC_WARMUP_MS = 400; export default function DandelionScreen() { const { width } = useWindowDimensions(); const insets = useSafeAreaInsets(); - const navigation = useNavigation(); const { paramsSynchronizable, setBreath, reset, getDetached } = useDandelionPhysics(); // Coarse view of the sim for the caption: 'full', 'some', or 'bald'. @@ -123,21 +121,12 @@ export default function DandelionScreen() { head={HEAD} /> {/* Tap anywhere to grow it back. A layer rather than a wrapping Pressable, - so Back and the caption stay separate accessibility nodes. */} + so the caption stays a separate accessibility node. */} - navigation.goBack()} - hitSlop={12} - accessibilityRole="button" - accessibilityLabel="Back" - style={[styles.back, { top: insets.top + 6 }]} - > - ‹ - { + const prev = lastSize.current; + if (prev.width === width && prev.height === height) { + return; + } + lastSize.current = { width, height }; + reset(); + const id = setTimeout(play, FOLD_SETTLE_MS); + return () => clearTimeout(id); + }, [width, height, play, reset]); + + const gesture = useMemo(() => { + const hold = Gesture.LongPress() + .runOnJS(true) + .minDuration(RESET_HOLD_MS) + .onStart(reset); + const tap = Gesture.Tap() + .runOnJS(true) + .onEnd((_event, success) => success && advance()); + return Gesture.Exclusive(hold, tap); + }, [advance, reset]); + + return ( + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, + fill: { + ...StyleSheet.absoluteFill, + }, +}); diff --git a/example/src/screens/FerrofluidScreen.tsx b/example/src/screens/FerrofluidScreen.tsx new file mode 100644 index 0000000..58ce06a --- /dev/null +++ b/example/src/screens/FerrofluidScreen.tsx @@ -0,0 +1,113 @@ +import { useMemo } from 'react'; +import { + type GestureResponderEvent, + Pressable, + StatusBar, + StyleSheet, + Text, + useWindowDimensions, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Ferrofluid from '../components/ferrofluid/Ferrofluid'; +import { useFerrofluidField } from '../hooks/useFerrofluidField'; + +/** + * Photo: "Marble Kymi Euboea Greece" by Jebulon, CC0, via Wikimedia Commons + * (https://commons.wikimedia.org/wiki/File:Marble_Kymi_Euboea_Greece.jpg), + * rotated to portrait. + */ +const MARBLE = require('../../assets/ferrofluid/marble.jpg'); +const IMG_W = 853; +const IMG_H = 1280; + +/** + * Ferrofluid that answers a real magnet. + * + * A puddle of black ferrofluid on a marble counter. Bring a magnet close to + * the phone and the magnetometer picks it up: the fluid creeps toward it and + * stands up into a field of sharp spikes that grow with the field. Without a + * magnetometer, touch and hold: the finger is the magnet. "Recalibrate" takes + * a fresh Earth-field baseline (do it with the magnet away). + */ +export default function FerrofluidScreen() { + const { width, height } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const { paramsSynchronizable, setTouch, calibrate, sensorAvailable } = + useFerrofluidField(width, height); + + const coverScale = useMemo<[number, number]>(() => { + const screenAspect = width / height; + const imgAspect = IMG_W / IMG_H; + return screenAspect < imgAspect + ? [screenAspect / imgAspect, 1] + : [1, imgAspect / screenAspect]; + }, [width, height]); + + const onTouch = (e: GestureResponderEvent) => { + setTouch({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }); + }; + const onRelease = () => setTouch(null); + + const hint = + sensorAvailable === false + ? 'Touch and hold to bring in a magnet' + : 'Hold a magnet near your phone'; + + return ( + + + + true} + onMoveShouldSetResponder={() => true} + onResponderGrant={onTouch} + onResponderMove={onTouch} + onResponderRelease={onRelease} + onResponderTerminate={onRelease} + /> + + {hint} + {sensorAvailable ? ( + + Recalibrate + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#d9d6d1', + }, + footer: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + gap: 10, + }, + hint: { + color: 'rgba(30, 30, 34, 0.62)', + fontSize: 14, + fontWeight: '600', + letterSpacing: 0.2, + }, + calibrate: { + color: 'rgba(30, 30, 34, 0.45)', + fontSize: 13, + fontWeight: '600', + }, +}); diff --git a/example/src/screens/FlashlightScreen.tsx b/example/src/screens/FlashlightScreen.tsx new file mode 100644 index 0000000..1a4ea7c --- /dev/null +++ b/example/src/screens/FlashlightScreen.tsx @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Image, + Pressable, + StyleSheet, + useWindowDimensions, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useParamsSynchronizable } from 'react-native-effects'; +import TorchScene from '../components/flashlight/TorchScene'; +import TorchIsland, { + type IslandMode, +} from '../components/flashlight/TorchIsland'; +import { useTorchAim } from '../hooks/useTorchAim'; + +/** + * Photo: "Grungy corridor of abandoned school" by Shixart1985, CC BY 2.0, via + * Wikimedia Commons + * (https://commons.wikimedia.org/wiki/File:Grungy_corridor_of_abandoned_school.jpg). + */ +const CORRIDOR = require('../../assets/flashlight/corridor.jpg'); +const CORRIDOR_ASPECT = 1068 / 1600; +const TORCH_OFF = require('../../assets/flashlight/flashlight-off.png'); +const CAMERA = require('../../assets/flashlight/camera.png'); + +/** pt of drag for a full sweep of beam width / intensity. */ +const DRAG_RANGE = { beam: 170, level: 210 }; + +function clamp01(v: number) { + return Math.max(0, Math.min(1, v)); +} + +function approach(current: number, target: number, rate: number, dt: number) { + return current + (target - current) * (1 - Math.exp(-rate * dt)); +} + +/** + * The iOS 18 torch in a dark corridor. The lock-screen button switches it on + * and the Dynamic Island grows into the flashlight control: drag up/down on + * it for intensity and left/right for beam width; the spot on the photo + * follows, and tilting the phone sweeps it across the corridor. + */ +export default function FlashlightScreen() { + const insets = useSafeAreaInsets(); + const { width, height } = useWindowDimensions(); + const aim = useTorchAim(); + const [isOn, setIsOn] = useState(false); + const [mode, setMode] = useState('idle'); + const target = useRef({ beam: 0.55, level: 0.75, power: 0 }); + const state = useRef({ beam: 0.55, level: 0.75, power: 0, x: 0, y: 0 }); + + const scene = useParamsSynchronizable([width / 2, height * 0.45, 0.55, 0]); + const island = useParamsSynchronizable([0.55, 0.75, 0, 0]); + const setScene = scene.setParamsSynchronizable; + const setIsland = island.setParamsSynchronizable; + + useEffect(() => { + let frame = 0; + let last = performance.now(); + const tick = () => { + const now = performance.now(); + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + const s = state.current; + const t = target.current; + s.beam = approach(s.beam, t.beam, 14, dt); + s.level = approach(s.level, t.level, 14, dt); + s.power = approach(s.power, t.power, 28, dt); + s.x = approach(s.x, aim.current.x, 7, dt); + s.y = approach(s.y, aim.current.y, 7, dt); + const spotX = width * (0.5 + 0.42 * s.x); + const spotY = height * (0.47 - 0.36 * s.y); + setScene(spotX, spotY, s.beam, s.power * (0.25 + 0.75 * s.level)); + setIsland(s.beam, s.level, s.power, 0); + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [aim, width, height, setScene, setIsland]); + + const setTorch = useCallback((on: boolean) => { + target.current.power = on ? 1 : 0; + setIsOn(on); + }, []); + + const onLockButton = useCallback(() => { + const next = !isOn; + setTorch(next); + setMode(next ? 'expanded' : 'idle'); + }, [isOn, setTorch]); + + const onIslandToggle = useCallback(() => setTorch(!isOn), [isOn, setTorch]); + + const onAdjust = useCallback( + (dx: number, dy: number) => { + const t = target.current; + t.beam = clamp01(t.beam + dx / DRAG_RANGE.beam); + t.level = clamp01(t.level - dy / DRAG_RANGE.level); + if (!isOn) setTorch(true); + }, + [isOn, setTorch] + ); + + const collapse = useCallback(() => { + if (mode === 'expanded') setMode(isOn ? 'compact' : 'idle'); + }, [mode, isOn]); + + return ( + + + + setMode('expanded')} + onToggle={onIslandToggle} + onAdjust={onAdjust} + /> + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, + quickRow: { + position: 'absolute', + left: 46, + right: 46, + flexDirection: 'row', + justifyContent: 'space-between', + }, + quickButton: { + width: 50, + height: 50, + borderRadius: 25, + backgroundColor: 'rgba(60,60,72,0.45)', + alignItems: 'center', + justifyContent: 'center', + }, + quickButtonOn: { + backgroundColor: 'rgba(235,236,245,0.92)', + }, + torchGlyph: { + width: 12, + height: 22, + }, + torchGlyphOn: { + tintColor: '#5b5bd6', + }, + torchGlyphOff: { + tintColor: '#fff', + }, + cameraGlyph: { + width: 24, + height: 18, + tintColor: '#fff', + }, + homeBar: { + position: 'absolute', + alignSelf: 'center', + bottom: 8, + width: 140, + height: 5, + borderRadius: 3, + backgroundColor: 'rgba(255,255,255,0.85)', + }, +}); diff --git a/example/src/screens/FoamLabScreen.tsx b/example/src/screens/FoamLabScreen.tsx index e61e454..1d8d952 100644 --- a/example/src/screens/FoamLabScreen.tsx +++ b/example/src/screens/FoamLabScreen.tsx @@ -18,11 +18,7 @@ import { ShaderView } from 'react-native-effects'; * yellowness so the lacy seam of half-transparent bubbles is what sits on * our own beer. */ -const VARIANTS = [ - '1 photo, as shot', - '2 photo, keyed edge', - '3 photo, keyed + whitened', -]; +const VARIANTS = ['As shot', 'Keyed edge', 'Keyed and whitened']; // Stock foam photo supplied as the reference — check its licence before it // ships in the real glass. diff --git a/example/src/screens/HoloCardScreen.tsx b/example/src/screens/HoloCardScreen.tsx index 2da00fe..61f8fb3 100644 --- a/example/src/screens/HoloCardScreen.tsx +++ b/example/src/screens/HoloCardScreen.tsx @@ -1,6 +1,6 @@ import { View, Text, StyleSheet, StatusBar, ScrollView } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Aurora, LiquidChrome, Iridescence } from 'react-native-effects'; -import { BackButton } from '../components/BackButton'; const CARDS = [ { @@ -72,15 +72,16 @@ function CardEffect({ } export default function HoloCardScreen() { + const insets = useSafeAreaInsets(); + return ( - - + Cards - Each card uses a different shader effect + Each card catches the light its own way @@ -146,7 +147,6 @@ const styles = StyleSheet.create({ backgroundColor: '#000', }, header: { - paddingTop: 54, paddingHorizontal: 24, paddingBottom: 16, }, diff --git a/example/src/screens/HoloFoilScreen.tsx b/example/src/screens/HoloFoilScreen.tsx index 8f557fc..5530e55 100644 --- a/example/src/screens/HoloFoilScreen.tsx +++ b/example/src/screens/HoloFoilScreen.tsx @@ -1,12 +1,9 @@ import { View, Text, StyleSheet, StatusBar } from 'react-native'; import { GestureDetector } from 'react-native-gesture-handler'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import HoloFoilCard from '../components/HoloFoilCard'; import { useTilt } from '../hooks/useTilt'; -import { BackButton } from '../components/BackButton'; export default function HoloFoilScreen() { - const insets = useSafeAreaInsets(); const { paramsSynchronizable, pan, source } = useTilt(); return ( @@ -37,16 +34,9 @@ export default function HoloFoilScreen() { - {source === 'motion' ? 'Tilt your phone 🌈' : 'Drag the card 🌈'} + {source === 'motion' ? 'Tilt your phone' : 'Drag the card'} - - - - ); } @@ -62,11 +52,6 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingHorizontal: 24, }, - backWrap: { - position: 'absolute', - left: 22, - zIndex: 100, - }, cardWrap: { width: '100%', aspectRatio: 1.586, diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index 19352ae..1073ef7 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -1,335 +1,114 @@ -import { - View, - Text, - StyleSheet, - TouchableOpacity, - ScrollView, - StatusBar, - Image, -} from 'react-native'; +import { useCallback, type ComponentType } from 'react'; +import { StatusBar, StyleSheet, Text, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import type { ExampleCategory, HomeScreenNavigationProp } from '../types'; +import Animated, { + useAnimatedScrollHandler, + useSharedValue, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Hero, useHeroHeight } from '../components/catalog/Hero'; +import { + Diptych, + EffectRows, + LeadStory, + LightWall, + OrbRail, + PosterRail, + TileRail, + WideStack, + type LayoutProps, +} from '../components/catalog/layouts'; +import { TopBar } from '../components/catalog/TopBar'; +import { + FEATURED_EFFECTS, + SECTIONS, + type CatalogSection, + type Effect, + type SectionLayout, +} from '../catalog/effects'; +import { colors, SCREEN_MARGIN, SERIF } from '../catalog/theme'; +import type { HomeScreenNavigationProp } from '../types'; -const EXAMPLE_CATEGORIES: ExampleCategory[] = [ - { - id: 'imessage', - title: 'iMessage Effects', - description: - 'Hold send to pick an effect — lasers, confetti, fireworks and more', - screen: 'IMessageExample', - color: '#0A84FF', - image: require('../../assets/components/imessage.png'), - }, - { - id: 'telegram-spoiler', - title: 'Telegram Spoiler', - description: - 'Tap the hidden text to reveal it, it hides again after a moment', - screen: 'TelegramSpoilerExample', - color: '#7C40FB', - image: require('../../assets/components/telegram-spoiler.png'), - }, - { - id: 'hourglass', - title: 'Hourglass', - description: 'Run a Pomodoro — tilt the sand, flip your phone to restart', - screen: 'HourglassExample', - color: '#8a6a3c', - image: require('../../assets/components/hourglass.png'), - }, - { - id: 'dandelion', - title: 'Dandelion', - description: 'Blow on your phone and the seeds drift away', - screen: 'DandelionExample', - color: '#7fae4a', - image: require('../../assets/components/dandelion.png'), - }, - { - id: 'cigarette', - title: 'Cigarette', - description: 'Hold to drag, tap to flick the ash, tilt the smoke', - screen: 'CigaretteExample', - color: '#ff6a1a', - image: require('../../assets/components/cigarette.png'), - }, - { - id: 'magic-8-ball', - title: 'Magic 8-ball', - description: 'Shake your phone and the answer floats up through the ink', - screen: 'Magic8BallExample', - color: '#2b3f8f', - image: require('../../assets/components/magic-8-ball.png'), - }, - { - id: 'voice-delete', - title: 'Voice Delete', - description: 'Instagram DM — delete a voice message', - screen: 'VoiceDeleteExample', - color: '#4C49FA', - image: require('../../assets/components/voice-delete.png'), - }, - { - id: 'particle-dispersion', - title: 'Particle Dispersion', - description: 'Reveal the subject — the background blows away as dust', - screen: 'ParticleDispersionExample', - color: '#6e625a', - image: require('../../assets/components/particle-dispersion.png'), - }, - { - id: 'birthday-candle', - title: 'Birthday Candle', - description: 'Blow on your phone to put it out, tap to relight', - screen: 'BirthdayCandleExample', - color: '#f2a93b', - image: require('../../assets/components/candle.png'), - }, - { - id: 'telegram-dissolve', - title: 'Telegram Dissolve', - description: 'Delete a message and watch it turn to dust', - screen: 'TelegramDissolveExample', - color: '#7C40FB', - image: require('../../assets/components/telegram-dissolve.png'), - }, - { - id: 'lighter', - title: 'Lighter', - description: 'Tap to strike, tilt and the flame stays upright', - screen: 'LighterExample', - color: '#ff9a3c', - image: require('../../assets/components/lighter.png'), - }, - { - id: 'beer', - title: 'Beer', - description: 'Tilt to slosh, rotate to drink, tap to refill', - screen: 'BeerExample', - color: '#e5920a', - // Placeholder art — replaced by a live capture once rendered. - image: require('../../assets/components/beer.png'), - }, - { - id: 'foamlab', - title: 'Foam lab', - description: 'Eight foam treatments side by side — pick one', - screen: 'FoamLabExample', - color: '#f3ead2', - image: require('../../assets/components/beer.png'), - }, - { - id: 'materials', - title: 'Materials', - description: 'Natural material banners — tap to go full screen', - screen: 'MaterialsExample', - color: '#8a6d4a', - // Placeholder art — replaced by a live capture once rendered. - image: require('../../assets/components/material.png'), - }, - { - id: 'ink-bloom', - title: 'Ink Bloom', - description: 'A drop of ink curling through water', - screen: 'InkBloomExample', - color: '#4338ca', - image: require('../../assets/components/ink.png'), - }, - { - id: 'banners', - title: 'Banners', - description: 'iOS notification banners with shader effects', - screen: 'BannersExample', - color: '#f59e0b', - // Placeholder art — replaced by a live capture once rendered. - image: require('../../assets/components/banners.png'), - }, - { - id: 'thanos', - title: 'Thanos', - description: 'Tap to snap a card into dust', - screen: 'ThanosExample', - color: '#a855f6', - image: require('../../assets/components/thanos.png'), - }, - { - id: 'ai-shimmer', - title: 'AI Shimmer', - description: 'Skeleton shimmer — restrained blue→violet', - screen: 'AiShimmerExample', - color: '#7c5cff', - image: require('../../assets/components/assistant.png'), - }, - { - id: 'ai-orb', - title: 'AI Orb', - description: 'Gemini-style morphing thinking blob', - screen: 'AiOrbExample', - color: '#5b8cff', - image: require('../../assets/components/ai-orb.png'), - }, - { - id: 'ai-voice', - title: 'AI Voice', - description: 'Simulated audio waveform, listening', - screen: 'AiVoiceExample', - color: '#5b8cff', - image: require('../../assets/components/waveform.png'), - }, - { - id: 'holo-foil', - title: 'Holo Foil', - description: 'Rainbow foil that shifts as you tilt', - screen: 'HoloFoilExample', - color: '#ec4899', - image: require('../../assets/components/holo.png'), - }, - { - id: 'weather-example', - title: 'Weather App', - description: 'Apple Weather UI clone', - screen: 'WeatherExample', - color: '#1a2a6c', - image: require('../../assets/components/weather.png'), - }, - { - id: 'scroll-reactive', - title: 'Scroll Reactive', - description: 'Background that reacts to your scroll', - screen: 'ScrollReactiveExample', - color: '#0ea5e9', - image: require('../../assets/components/scroll.png'), - }, - { - id: 'touch-field', - title: 'Sauron Eye', - description: 'The lidless Eye, wreathed in flame', - screen: 'TouchFieldExample', - color: '#c40000', - // Placeholder art — swap for a real screen-recording still once captured. - image: require('../../assets/components/field.png'), - }, - { - id: 'iridescence', - title: 'Iridescence', - description: 'Mesmerizing iridescent animated backgrounds', - screen: 'IridescenceStatic', - color: '#8b5cf6', - image: require('../../assets/components/iridescence.png'), - }, - { - id: 'liquid-chrome', - title: 'Liquid Chrome', - description: 'Fluid metallic surfaces', - screen: 'LiquidChromeStatic', - color: '#64748b', - image: require('../../assets/components/liquid-chrome.png'), - }, - { - id: 'silk', - title: 'Silk', - description: 'Smooth flowing silk fabric', - screen: 'SilkStatic', - color: '#7B7481', - image: require('../../assets/components/silk.png'), - }, - { - id: 'campfire', - title: 'Campfire', - description: 'Fire with drifting sparks and smoke', - screen: 'CampfireStatic', - color: '#ff9933', - image: require('../../assets/components/campfire.png'), - }, - { - id: 'calico-swirl', - title: 'Calico Swirl', - description: 'Warped noise pattern with flowing colors', - screen: 'CalicoSwirlStatic', - color: '#6366f1', - image: require('../../assets/components/calico-swirl.png'), - }, - { - id: 'aurora', - title: 'Aurora', - description: 'Northern lights with flowing curtains of light', - screen: 'AuroraStatic', - color: '#4ade80', - image: require('../../assets/components/aurora.png'), - }, - { - id: 'siri-orb', - title: 'Siri Orb', - description: 'Luminous breathing sphere with swirling colors', - screen: 'SiriOrbStatic', - color: '#a855f6', - image: require('../../assets/components/siri-orb.png'), - }, - { - id: 'holo-card', - title: 'Cards', - description: 'Neobank iridescent credit cards', - screen: 'HoloCardExample', - color: '#6366f1', - image: require('../../assets/components/cards.png'), - }, - { - id: 'circular-gradient', - title: 'Circular Gradient', - description: 'Customizable circular gradients', - screen: 'CircularGradientList', - color: '#4f46e5', - image: require('../../assets/components/circular-gradient.png'), - }, +const LAYOUTS: Record> = { + posters: PosterRail, + diptych: Diptych, + lead: LeadStory, + tiles: TileRail, + wide: WideStack, + orbs: OrbRail, + rows: EffectRows, + wall: LightWall, +}; - { - id: 'linear-gradient', - title: 'Linear Gradient', - description: 'Smooth linear gradients', - screen: 'LinearGradientList', - color: '#ec4899', - image: require('../../assets/components/linear-gradient.png'), - }, -]; +const HERO_CAPTION_CLEARANCE = 190; + +function sectionKey(section: CatalogSection) { + return section.id; +} export default function HomeScreen() { const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const heroHeight = useHeroHeight(); + const scrollY = useSharedValue(0); - return ( - - + const onScroll = useAnimatedScrollHandler((event) => { + scrollY.value = event.contentOffset.y; + }); - - React Native Effects - - WebGPU-powered effects running on background thread in React Native 🧑‍🍳 - - + const openEffect = useCallback( + (effect: Effect) => navigation.navigate(effect.screen as never), + [navigation] + ); - - {EXAMPLE_CATEGORIES.map((category) => ( - navigation.navigate(category.screen as never)} - activeOpacity={0.7} + const renderSection = useCallback( + ({ item }: { item: CatalogSection }) => { + const Layout = LAYOUTS[item.layout]; + return ( + + - + {item.title} + + + + ); + }, + [openEffect] + ); - - {category.title} - {category.description} - - - → - - - ))} - + return ( + + + + } + ListFooterComponent={} + onScroll={onScroll} + scrollEventThrottle={16} + showsVerticalScrollIndicator={false} + contentInsetAdjustmentBehavior="never" + initialNumToRender={2} + maxToRenderPerBatch={2} + windowSize={5} + /> + navigation.navigate('Search')} + onAbout={() => navigation.navigate('About')} + /> ); } @@ -337,111 +116,19 @@ export default function HomeScreen() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#000', + backgroundColor: colors.background, }, - header: { - paddingTop: 60, - paddingHorizontal: 28, - paddingBottom: 28, - backgroundColor: '#111', - borderBottomWidth: 1, - borderBottomColor: '#222', + section: { + marginTop: 44, }, - title: { + sectionTitle: { + marginBottom: 16, + paddingHorizontal: SCREEN_MARGIN, + fontFamily: SERIF, fontSize: 34, - fontWeight: '800', - color: '#fff', - marginBottom: 10, - letterSpacing: -0.5, - }, - subtitle: { - fontSize: 16, - color: '#999', - fontWeight: '400', - }, - scrollView: { - flex: 1, - }, - scrollContent: { - padding: 20, - paddingTop: 28, - paddingBottom: 120, - }, - card: { - backgroundColor: '#111', - borderRadius: 18, - padding: 20, - marginBottom: 20, - flexDirection: 'row', - alignItems: 'center', - borderWidth: 1, - borderColor: '#252525', - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 4, - }, - shadowOpacity: 0.4, - shadowRadius: 12, - elevation: 6, - }, - cardImage: { - width: 80, - height: 80, - borderRadius: 12, - marginRight: 16, - backgroundColor: '#000', - }, - cardColorIndicator: { - width: 5, - height: 64, - borderRadius: 2.5, - marginRight: 18, - }, - cardContent: { - flex: 1, - paddingRight: 12, - }, - cardTitle: { - fontSize: 19, - fontWeight: '700', - color: '#fff', - marginBottom: 6, - letterSpacing: -0.3, - }, - cardDescription: { - fontSize: 14, - color: '#aaa', - lineHeight: 21, - }, - cardArrow: { - width: 32, - height: 32, - borderRadius: 16, - backgroundColor: '#222', - justifyContent: 'center', - alignItems: 'center', - }, - arrowText: { - fontSize: 20, - color: '#fff', + lineHeight: 40, fontWeight: '600', - }, - footer: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - paddingVertical: 28, - paddingHorizontal: 24, - backgroundColor: '#111', - borderTopWidth: 1, - borderTopColor: '#222', - }, - footerText: { - fontSize: 14, - color: '#777', - textAlign: 'center', - fontWeight: '500', + letterSpacing: -0.4, + color: colors.text, }, }); diff --git a/example/src/screens/HourglassScreen.tsx b/example/src/screens/HourglassScreen.tsx index 42fd8b2..0a6e50e 100644 --- a/example/src/screens/HourglassScreen.tsx +++ b/example/src/screens/HourglassScreen.tsx @@ -7,8 +7,6 @@ import { useWindowDimensions, View, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Hourglass from '../components/hourglass/Hourglass'; import { DURATIONS_MIN, @@ -57,8 +55,6 @@ function formatTime(seconds: number): string { */ export default function HourglassScreen() { const { width, height } = useWindowDimensions(); - const insets = useSafeAreaInsets(); - const navigation = useNavigation(); const { paramsSynchronizable, setDuration, togglePause, getState } = useHourglassPhysics(); @@ -108,7 +104,7 @@ export default function HourglassScreen() { grain={GRAIN} /> {/* Tap the glass to pause. A layer rather than a wrapping Pressable, so - Back, the timer and the chips stay separate accessibility nodes. */} + the timer and the chips stay separate accessibility nodes. */} - navigation.goBack()} - hitSlop={12} - accessibilityRole="button" - accessibilityLabel="Back" - style={[styles.back, { top: insets.top + 6 }]} - > - ‹ - {paused ? ( - paused — tap to resume + Paused. Tap to resume ) : null} - - - - - Ink in water - tap anywhere to drop ink + Tap anywhere to drop ink ); @@ -146,11 +137,6 @@ const styles = StyleSheet.create({ // Matches the unlit corners of the tank, so mounting does not flash. backgroundColor: '#000', }, - topBar: { - position: 'absolute', - left: 22, - zIndex: 10, - }, hintWrap: { position: 'absolute', left: 0, @@ -159,17 +145,6 @@ const styles = StyleSheet.create({ }, // The tank is lit now, not black, so the caption needs a shadow to stay // legible over the brighter water. - hintTitle: { - color: 'rgba(255,255,255,0.94)', - fontSize: 15, - fontWeight: '700', - letterSpacing: 2.2, - textTransform: 'uppercase', - marginBottom: 6, - textShadowColor: 'rgba(0,20,26,0.75)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 6, - }, hint: { color: 'rgba(255,255,255,0.62)', fontSize: 13, diff --git a/example/src/screens/IridescenceScreen.tsx b/example/src/screens/IridescenceScreen.tsx index 16278f7..bb4072c 100644 --- a/example/src/screens/IridescenceScreen.tsx +++ b/example/src/screens/IridescenceScreen.tsx @@ -1,28 +1,11 @@ -import { View, Text, StyleSheet, StatusBar } from 'react-native'; +import { StatusBar, StyleSheet, View } from 'react-native'; import { Iridescence } from 'react-native-effects'; -import { Header } from '../components/Header'; export default function IridescenceScreen() { return ( - - - -
- - - - Iridescence - - A mesmerizing iridescent effect with flowing colors - - - + ); } @@ -32,32 +15,4 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#000', }, - content: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 40, - }, - infoCard: { - backgroundColor: 'rgba(0, 0, 0, 0.4)', - borderRadius: 24, - padding: 32, - alignItems: 'center', - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.2)', - }, - title: { - fontSize: 32, - fontWeight: '800', - color: '#fff', - marginBottom: 12, - textAlign: 'center', - letterSpacing: -0.5, - }, - subtitle: { - fontSize: 16, - color: 'rgba(255, 255, 255, 0.8)', - textAlign: 'center', - fontWeight: '400', - }, }); diff --git a/example/src/screens/LavaLampScreen.tsx b/example/src/screens/LavaLampScreen.tsx new file mode 100644 index 0000000..668e8f6 --- /dev/null +++ b/example/src/screens/LavaLampScreen.tsx @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; +import { StatusBar, StyleSheet, useWindowDimensions, View } from 'react-native'; +import LavaLamp from '../components/lavalamp/LavaLamp'; +import { useLavaLamp } from '../hooks/useLavaLamp'; + +/** + * Photo: "Lava lamp (yellow)" by DMGualtieri, CC BY-SA 4.0, via Wikimedia + * Commons (https://commons.wikimedia.org/wiki/File:Lava_lamp_(yellow).jpg). + * Its glass is redrawn by the shader; the cap, base and room are the photo. + */ +const LAMP = require('../../assets/lavalamp/lava-lamp.jpg'); +const IMG_W = 1280; +const IMG_H = 1795; + +/** + * A lava lamp on a table in the evening. The wax heats at the bottom, rises, + * cools and sinks at a lava lamp's own pace. Tilt the phone and the wax + * drifts along real gravity. + */ +export default function LavaLampScreen() { + const { width, height } = useWindowDimensions(); + const { paramsSynchronizable } = useLavaLamp(); + + const coverScale = useMemo<[number, number]>(() => { + const screenAspect = width / height; + const imgAspect = IMG_W / IMG_H; + return screenAspect < imgAspect + ? [screenAspect / imgAspect, 1] + : [1, imgAspect / screenAspect]; + }, [width, height]); + + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#120c08', + }, +}); diff --git a/example/src/screens/LinearGradient/LinearGradientAnimatedScreen.tsx b/example/src/screens/LinearGradient/LinearGradientAnimatedScreen.tsx index 6a9a7f8..dc00f1f 100644 --- a/example/src/screens/LinearGradient/LinearGradientAnimatedScreen.tsx +++ b/example/src/screens/LinearGradient/LinearGradientAnimatedScreen.tsx @@ -6,7 +6,6 @@ import { StatusBar, } from 'react-native'; import { LinearGradient } from 'react-native-effects'; -import { BackButton } from '../../components/BackButton'; import { useState, useCallback } from 'react'; type ColorScheme = { @@ -50,16 +49,8 @@ export default function LinearGradientAnimatedScreen() { - - - - - Animated Linear Gradient - - Watch the gradient rotate continuously - {currentScheme.name} @@ -68,13 +59,13 @@ export default function LinearGradientAnimatedScreen() { style={styles.controlButton} onPress={handlePreviousScheme} > - ← Previous + Previous - Next → + Next @@ -108,11 +99,6 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#000', }, - header: { - paddingTop: 60, - paddingHorizontal: 28, - paddingBottom: 20, - }, content: { flex: 1, justifyContent: 'center', @@ -128,21 +114,6 @@ const styles = StyleSheet.create({ borderColor: 'rgba(255, 255, 255, 0.2)', marginBottom: 40, }, - title: { - fontSize: 32, - fontWeight: '800', - color: '#fff', - marginBottom: 12, - textAlign: 'center', - letterSpacing: -0.5, - }, - subtitle: { - fontSize: 16, - color: 'rgba(255, 255, 255, 0.8)', - textAlign: 'center', - marginBottom: 24, - fontWeight: '400', - }, schemeName: { fontSize: 24, fontWeight: '700', diff --git a/example/src/screens/LinearGradient/LinearGradientListScreen.tsx b/example/src/screens/LinearGradient/LinearGradientListScreen.tsx index c863438..70eece7 100644 --- a/example/src/screens/LinearGradient/LinearGradientListScreen.tsx +++ b/example/src/screens/LinearGradient/LinearGradientListScreen.tsx @@ -1,36 +1,24 @@ -import { - View, - Text, - StyleSheet, - TouchableOpacity, - ScrollView, - StatusBar, -} from 'react-native'; +import { ScrollView, StyleSheet } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import { Header } from '../../components/Header'; -import type { GradientExample } from './types'; +import { EffectRow } from '../../components/catalog/EffectRow'; +import type { Effect } from '../../catalog/effects'; +import { colors } from '../../catalog/theme'; import type { HomeScreenNavigationProp } from '../../types'; -const LINEAR_GRADIENT_EXAMPLES: GradientExample[] = [ +const EXAMPLES: Effect[] = [ { id: 'static', - title: 'Static Gradient', - description: 'Beautiful static linear gradients with various directions', + title: 'Static', + line: 'Presets at different angles', screen: 'LinearGradientStatic', - preview: { - startColor: '#ec4899', - endColor: '#8b5cf6', - }, + image: require('../../../assets/catalog/linear-gradient.jpg'), }, { id: 'animated', - title: 'Animated Gradient', - description: 'Dynamic gradient with smooth color transitions and rotations', + title: 'Animated', + line: 'Colors that rotate continuously', screen: 'LinearGradientAnimated', - preview: { - startColor: '#06b6d4', - endColor: '#3b82f6', - }, + image: require('../../../assets/catalog/linear-gradient-animated.jpg'), }, ]; @@ -38,138 +26,25 @@ export default function LinearGradientListScreen() { const navigation = useNavigation(); return ( - - - -
- - - {LINEAR_GRADIENT_EXAMPLES.map((example) => ( - navigation.navigate(example.screen as never)} - activeOpacity={0.7} - > - - - - - - {example.title} - {example.description} - - - → - - - ))} - - + + {EXAMPLES.map((example, index) => ( + navigation.navigate(effect.screen as never)} + showSeparator={index < EXAMPLES.length - 1} + /> + ))} + ); } const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#000', - }, - title: { - fontSize: 32, - fontWeight: '800', - color: '#fff', - marginBottom: 10, - letterSpacing: -0.5, - }, - subtitle: { - fontSize: 16, - color: '#999', - fontWeight: '400', - }, - scrollView: { - flex: 1, - }, - scrollContent: { - padding: 20, - paddingTop: 28, - paddingBottom: 40, - }, - card: { - backgroundColor: '#111', - borderRadius: 18, - padding: 20, - marginBottom: 20, - flexDirection: 'row', - alignItems: 'center', - borderWidth: 1, - borderColor: '#252525', - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 4, - }, - shadowOpacity: 0.4, - shadowRadius: 12, - elevation: 6, - }, - previewBox: { - width: 64, - height: 64, - borderRadius: 12, - marginRight: 18, - flexDirection: 'row', - overflow: 'hidden', - borderWidth: 2, - borderColor: '#333', - }, - previewGradient: { - flex: 1, - height: '100%', - }, - cardContent: { - flex: 1, - paddingRight: 12, - }, - cardTitle: { - fontSize: 19, - fontWeight: '700', - color: '#fff', - marginBottom: 6, - letterSpacing: -0.3, - }, - cardDescription: { - fontSize: 14, - color: '#aaa', - lineHeight: 21, - }, - cardArrow: { - width: 32, - height: 32, - borderRadius: 16, - backgroundColor: '#222', - justifyContent: 'center', - alignItems: 'center', + backgroundColor: colors.background, }, - arrowText: { - fontSize: 20, - color: '#fff', - fontWeight: '600', + content: { + paddingTop: 8, }, }); diff --git a/example/src/screens/LinearGradient/LinearGradientStaticScreen.tsx b/example/src/screens/LinearGradient/LinearGradientStaticScreen.tsx index e75c6fe..1a7d4f4 100644 --- a/example/src/screens/LinearGradient/LinearGradientStaticScreen.tsx +++ b/example/src/screens/LinearGradient/LinearGradientStaticScreen.tsx @@ -7,7 +7,6 @@ import { Dimensions, } from 'react-native'; import { LinearGradient } from 'react-native-effects'; -import { Header } from '../../components/Header'; const { width } = Dimensions.get('window'); const CARD_WIDTH = width - 80; @@ -98,11 +97,6 @@ export default function LinearGradientStaticScreen() { -
- ))} - - - - All gradients rendered with WebGPU - - ); @@ -165,7 +153,7 @@ const styles = StyleSheet.create({ }, scrollContent: { padding: 40, - paddingTop: 40, + paddingTop: 16, paddingBottom: 80, alignItems: 'center', }, @@ -177,14 +165,6 @@ const styles = StyleSheet.create({ overflow: 'hidden', borderWidth: 1, borderColor: '#252525', - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 8, - }, - shadowOpacity: 0.5, - shadowRadius: 16, - elevation: 10, }, gradientWrapper: { width: '100%', @@ -236,14 +216,4 @@ const styles = StyleSheet.create({ fontWeight: '600', marginTop: 4, }, - footer: { - marginTop: 20, - paddingVertical: 20, - }, - footerText: { - fontSize: 14, - color: '#555', - textAlign: 'center', - fontWeight: '500', - }, }); diff --git a/example/src/screens/LinearGradient/types.ts b/example/src/screens/LinearGradient/types.ts deleted file mode 100644 index a51983d..0000000 --- a/example/src/screens/LinearGradient/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { RootStackParamList } from '../../types'; - -export type GradientExample = { - id: string; - title: string; - description: string; - screen: keyof RootStackParamList; - preview: { - startColor: string; - endColor: string; - }; -}; diff --git a/example/src/screens/LiquidChromeScreen.tsx b/example/src/screens/LiquidChromeScreen.tsx index 985496b..949e5c8 100644 --- a/example/src/screens/LiquidChromeScreen.tsx +++ b/example/src/screens/LiquidChromeScreen.tsx @@ -1,28 +1,11 @@ -import { View, Text, StyleSheet, StatusBar } from 'react-native'; +import { StatusBar, StyleSheet, View } from 'react-native'; import { LiquidChrome } from 'react-native-effects'; -import { Header } from '../components/Header'; export default function LiquidChromeScreen() { return ( - - - -
- - - - Liquid Chrome - - A fluid metallic surface with chromatic distortion - - - + ); } @@ -32,32 +15,4 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#000', }, - content: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 40, - }, - infoCard: { - backgroundColor: 'rgba(0, 0, 0, 0.4)', - borderRadius: 24, - padding: 32, - alignItems: 'center', - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.2)', - }, - title: { - fontSize: 32, - fontWeight: '800', - color: '#fff', - marginBottom: 12, - textAlign: 'center', - letterSpacing: -0.5, - }, - subtitle: { - fontSize: 16, - color: 'rgba(255, 255, 255, 0.8)', - textAlign: 'center', - fontWeight: '400', - }, }); diff --git a/example/src/screens/Magic8BallScreen.tsx b/example/src/screens/Magic8BallScreen.tsx index 65ff47d..459cdab 100644 --- a/example/src/screens/Magic8BallScreen.tsx +++ b/example/src/screens/Magic8BallScreen.tsx @@ -13,7 +13,6 @@ import Animated, { useAnimatedStyle, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { BackButton } from '../components/BackButton'; import Magic8Ball from '../components/magic8ball/Magic8Ball'; import { useMagic8BallPhysics } from '../hooks/useMagic8BallPhysics'; @@ -211,12 +210,6 @@ export default function Magic8BallScreen() { {/* No title block: the object is the screen, and a heading over it only competes with the ball for the same dark space. */} - - - Ask something, then shake it @@ -229,11 +222,6 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#08090c', }, - backWrap: { - position: 'absolute', - left: 22, - zIndex: 100, - }, answerBox: { position: 'absolute', alignItems: 'center', diff --git a/example/src/screens/MaterialsScreen.tsx b/example/src/screens/MaterialsScreen.tsx index c520aff..e167eaf 100644 --- a/example/src/screens/MaterialsScreen.tsx +++ b/example/src/screens/MaterialsScreen.tsx @@ -28,7 +28,6 @@ import Animated, { withTiming, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { BackButton } from '../components/BackButton'; import LeatherMaterial from '../components/materials/LeatherMaterial'; import WoodMaterial from '../components/materials/WoodMaterial'; import SandMaterial from '../components/materials/SandMaterial'; @@ -89,7 +88,7 @@ const MATERIALS: MaterialDef[] = [ key: 'metal', caption: 'Brushed stainless, wet', title: 'Metal', - hint: 'Hold to pool water — it runs', + hint: 'Hold to pool water, then let it run', Component: MetalMaterial, }, ]; @@ -237,13 +236,6 @@ export default function MaterialsScreen() { ))} - - - - {expanded && rect && ( void; + __nameDropWaveAt?: (seconds: number) => void; + __nameDropSpeed?: (factor: number | null) => void; +}; + +let playbackSpeed = 1; + +async function snapshotPoster(view: PosterHandle) { + const raw = await captureRef(view, { + format: 'png', + quality: 1, + result: 'tmpfile', + }); + return raw.startsWith('file://') ? raw : `file://${raw}`; +} + +/** + * NameDrop: drag down from the top of the home screen (or tap it) to bring + * the other iPhone close. A coloured glow climbs the screen and pools under + * the Dynamic Island, a liquid wave of light bursts out of it, and the other + * person's "Share Your Contact" poster swells into focus. Tap it to go back. + */ +export default function NameDropScreen() { + const { width, height } = useWindowDimensions(); + const [phase, setPhase] = useState('home'); + const [choice, setChoice] = useState(null); + const [snapshot, setSnapshot] = useState(null); + const { paramsSynchronizable } = useParamsSynchronizable([0, 0, 0, 0]); + const posterRef = useRef(null); + const resetTimer = useRef | null>(null); + const isPlaying = useRef(false); + const clock = useSharedValue(0); + const cover = useSharedValue(1); + + useAnimatedReaction( + () => clock.value, + (seconds) => { + paramsSynchronizable.setBlocking(new Float64Array([seconds, 0, 0, 0])); + } + ); + + const stopClock = useCallback(() => { + cancelAnimation(clock); + isPlaying.current = false; + if (resetTimer.current !== null) clearTimeout(resetTimer.current); + resetTimer.current = null; + }, [clock]); + + useEffect(() => stopClock, [stopClock]); + + const showPoster = useCallback(() => { + isPlaying.current = false; + setPhase('poster'); + }, []); + + const play = useCallback(() => { + stopClock(); + isPlaying.current = true; + setChoice(null); + setPhase('exchanging'); + cover.value = 1; + clock.value = 0; + clock.value = withTiming( + REVEAL_SECONDS, + { + duration: (REVEAL_SECONDS * 1000) / playbackSpeed, + easing: Easing.linear, + }, + (finished) => { + if (!finished) return; + cover.value = 0; + scheduleOnRN(showPoster); + } + ); + }, [stopClock, cover, clock, showPoster]); + + const goHome = useCallback(() => { + stopClock(); + clock.value = 0; + setPhase('home'); + cover.value = withTiming(1, { duration: 320 }); + }, [stopClock, cover, clock]); + + const choose = useCallback( + (picked: 'share' | 'receive') => { + setChoice(picked); + resetTimer.current = setTimeout(goHome, RESET_AFTER_CHOICE_MS); + }, + [goHome] + ); + + const captureSnapshot = useCallback(() => { + if (!posterRef.current) return; + snapshotPoster(posterRef.current) + .then(setSnapshot) + .catch((e) => console.warn('[NameDrop] poster snapshot failed:', e)); + }, []); + + // `__nameDrop()` plays the exchange, `__nameDropWaveAt(seconds)` freezes + // it at one moment and `__nameDropSpeed(factor | null)` slows it down. + useEffect(() => { + if (!__DEV__) return; + const g = globalThis as NameDropDevHooks; + g.__nameDrop = play; + g.__nameDropWaveAt = (seconds) => { + stopClock(); + clock.value = seconds; + const done = seconds >= REVEAL_SECONDS; + setPhase(done ? 'poster' : 'exchanging'); + cover.value = done ? 0 : 1; + }; + g.__nameDropSpeed = (factor) => { + playbackSpeed = factor ?? 1; + }; + return () => { + delete g.__nameDrop; + delete g.__nameDropWaveAt; + delete g.__nameDropSpeed; + }; + }, [play, stopClock, cover, clock]); + + const approach = Gesture.Pan() + .runOnJS(true) + .enabled(phase === 'home') + .activeOffsetY(12) + .onUpdate((e) => { + if (e.translationY > TRIGGER_DRAG && !isPlaying.current) play(); + }); + const tap = Gesture.Tap() + .runOnJS(true) + .enabled(phase === 'home') + .onEnd(play); + const homeGesture = Gesture.Exclusive(approach, tap); + + const coverStyle = useAnimatedStyle(() => ({ opacity: cover.value })); + + return ( + + + + + + + + + + + {snapshot !== null && ( + + )} + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, + clip: { + overflow: 'hidden', + }, +}); diff --git a/example/src/screens/ParticleDispersionScreen.tsx b/example/src/screens/ParticleDispersionScreen.tsx index 23ca5f0..d9759c9 100644 --- a/example/src/screens/ParticleDispersionScreen.tsx +++ b/example/src/screens/ParticleDispersionScreen.tsx @@ -7,7 +7,6 @@ import { Text, View, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Animated, { Easing, @@ -54,7 +53,6 @@ const LABEL: Record = { }; export default function ParticleDispersionScreen() { - const navigation = useNavigation(); const insets = useSafeAreaInsets(); const [phase, setPhase] = useState('idle'); // The question shows up once the background is gone. @@ -148,14 +146,6 @@ export default function ParticleDispersionScreen() { backdrop={BACKDROP} /> - navigation.goBack()} - hitSlop={12} - style={[styles.back, { top: insets.top + 6 }]} - > - ‹ - - {revealed && ( void }; + +function makePrint( + id: number, + z: number, + photo: ImageSourcePropType +): PrintSpec { + return { + id, + z, + photo, + seed: Math.random(), + angle: (Math.random() - 0.5) * 16, + dx: (Math.random() - 0.5) * 56, + dy: (Math.random() - 0.5) * 44, + }; +} + +function clamp(value: number, min: number, max: number) { + 'worklet'; + return Math.min(max, Math.max(min, value)); +} + +/** + * One print on the table. It ejects from the camera, develops on its own + * clock and can be dragged around; the print on top hangs from the fingers + * and sways when the phone is shaken. + */ +function Print({ + spec, + width, + height, + travel, + bounds, + shakeLevel, + sway, + isTop, + paused, + onLift, +}: { + spec: PrintSpec; + width: number; + height: number; + travel: number; + bounds: DragBounds; + shakeLevel: React.RefObject; + sway: SharedValue; + isTop: boolean; + paused: boolean; + onLift: (id: number) => void; +}) { + const { paramsSynchronizable, setParamsSynchronizable } = + useParamsSynchronizable([0, 0, spec.seed, 0]); + const [developed, setDeveloped] = useState(false); + const rise = useSharedValue(travel); + const angle = useSharedValue(0); + const topFlag = useSharedValue(isTop ? 1 : 0); + const dragX = useSharedValue(0); + const dragY = useSharedValue(0); + const lift = useSharedValue(0); + const dev = useRef(0); + + useEffect(() => { + topFlag.value = withTiming(isTop ? 1 : 0, { duration: 400 }); + }, [isTop, topFlag]); + + useEffect(() => { + rise.value = withTiming(0, { + duration: EJECT_MS, + easing: Easing.bezier(0.2, 0.0, 0.35, 1), + }); + angle.value = withSpring(spec.angle, { damping: 14, stiffness: 60 }); + }, [rise, angle, spec.angle]); + + useEffect(() => { + let last = Date.now(); + let frame = 0; + const step = () => { + const now = Date.now(); + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + const boost = 1 + SHAKE_BOOST * (shakeLevel.current ?? 0); + dev.current = Math.min(1, dev.current + (dt / DEVELOP_SECONDS) * boost); + if (dev.current === 1) { + setParamsSynchronizable(1, 0, spec.seed, 0); + setDeveloped(true); + return; + } + setParamsSynchronizable(dev.current, sway.value / 9, spec.seed, 0); + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + return () => cancelAnimationFrame(frame); + }, [setParamsSynchronizable, shakeLevel, spec.seed, sway]); + + const pan = Gesture.Pan() + .onBegin(() => { + runOnJS(onLift)(spec.id); + lift.value = withTiming(1, { duration: 120 }); + }) + .onChange((e) => { + dragX.value = clamp(dragX.value + e.changeX, bounds.minX, bounds.maxX); + dragY.value = clamp(dragY.value + e.changeY, bounds.minY, bounds.maxY); + }) + .onFinalize(() => { + lift.value = withTiming(0, { duration: 200 }); + }); + + const style = useAnimatedStyle(() => ({ + transform: [ + { translateX: spec.dx + dragX.value }, + { translateY: rise.value + spec.dy + dragY.value }, + { rotate: `${angle.value + sway.value * topFlag.value}deg` }, + { scale: 1 + lift.value * 0.04 }, + ], + })); + + return ( + + + + + + + + ); +} + +/** + * Polaroid instant print. + * + * Press the shutter: it clicks, the camera takes a real photo (or a bundled + * one on the simulator or without permission), the film rolls and a print + * slides out onto the table. It starts as the milky blue-grey of the + * opacifier and develops over about twenty seconds in the order the film does + * it. Shake the phone and the top print swings in your fingers while every wet + * print develops a little faster. Prints pile up, newest on top, and can be dragged + * apart; a finished print is frozen and costs no GPU time. + * `__polaroidPhoto(uri)` prints any image in dev builds, through the same + * square crop a real photo takes. + */ +export default function PolaroidScreen() { + const { width, height } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const { level, sway } = usePolaroidShake(); + const camera = useRef(null); + const cropper = useRef(null); + const nextId = useRef(0); + const nextZ = useRef(0); + const busy = useRef(false); + const left = useRef(false); + const [prints, setPrints] = useState([]); + const [bundledIndex, setBundledIndex] = useState(0); + const [pausedBelowId, setPausedBelowId] = useState(0); + + const printWidth = Math.min(width * 0.66, 300); + const printHeight = printWidth / PRINT_ASPECT; + const bodyTop = height - CAMERA_BODY_HEIGHT - insets.bottom; + const printTop = Math.max( + insets.top + 24, + (bodyTop - printHeight) * 0.5 + insets.top * 0.3 + ); + const printLeft = (width - printWidth) / 2; + const travel = bodyTop - printTop; + const topZ = Math.max(...prints.map((p) => p.z)); + + const bounds: DragBounds = { + minX: -printLeft - printWidth * 0.4, + maxX: printLeft + printWidth * 0.4, + minY: -printTop - printHeight * 0.3, + maxY: bodyTop - printTop - printHeight * 0.45, + }; + + const addPrint = useCallback((photo: ImageSourcePropType) => { + const spec = makePrint(nextId.current++, ++nextZ.current, photo); + setPausedBelowId(spec.id); + setTimeout(() => { + if (left.current) { + return; + } + playFilmWhirr(); + setPrints((ps) => [...ps, spec].slice(-MAX_PRINTS)); + }, GPU_PAUSE_LEAD_MS); + setTimeout(() => setPausedBelowId(0), GPU_PAUSE_LEAD_MS + GPU_PAUSE_MS); + }, []); + + const liftPrint = useCallback((id: number) => { + const z = ++nextZ.current; + setPrints((ps) => ps.map((p) => (p.id === id ? { ...p, z } : p))); + }, []); + + const squarePhoto = useCallback(async () => { + const uri = await camera.current?.takePhoto(); + return uri ? cropper.current?.crop(uri) : null; + }, []); + + const shoot = useCallback(async () => { + if (busy.current) { + return; + } + busy.current = true; + camera.current?.blink(); + playShutterClick(); + const photo = await squarePhoto().catch(() => null); + setTimeout(() => { + busy.current = false; + }, EJECT_MS); + if (photo) { + addPrint({ uri: photo }); + return; + } + addPrint(PHOTOS[bundledIndex % PHOTOS.length]!); + setBundledIndex(bundledIndex + 1); + }, [addPrint, bundledIndex, squarePhoto]); + + useEffect(() => { + left.current = false; + return () => { + left.current = true; + stopCameraSound(); + }; + }, []); + + useEffect(() => { + if (!__DEV__) { + return; + } + const debug = globalThis as DebugGlobal; + debug.__polaroidPhoto = (uri) => { + cropper.current?.crop(uri).then((photo) => addPrint({ uri: photo })); + }; + return () => { + delete debug.__polaroidPhoto; + }; + }, [addPrint]); + + return ( + + + + + + + + {prints.map((p) => ( + + ))} + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#2a1a10', + }, + shade: { + ...StyleSheet.absoluteFill, + backgroundColor: 'rgba(20, 10, 4, 0.18)', + }, + stage: { + position: 'absolute', + }, + print: { + position: 'absolute', + left: 0, + top: 0, + shadowColor: '#000', + shadowOpacity: 0.45, + shadowRadius: 14, + shadowOffset: { width: 0, height: 8 }, + backgroundColor: '#efeee9', + borderRadius: 3, + }, + paper: { + flex: 1, + borderRadius: 3, + overflow: 'hidden', + }, +}); diff --git a/example/src/screens/ScratchOffScreen.tsx b/example/src/screens/ScratchOffScreen.tsx new file mode 100644 index 0000000..8ae6be9 --- /dev/null +++ b/example/src/screens/ScratchOffScreen.tsx @@ -0,0 +1,294 @@ +import { useCallback, useState } from 'react'; +import { + Pressable, + StatusBar, + StyleSheet, + Text, + useWindowDimensions, + View, +} from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Flakes from '../components/scratchoff/Flakes'; +import ScratchCoating, { + AREAS, + PLAY_H, + PLAY_W, + type PrizeSymbol, +} from '../components/scratchoff/ScratchCoating'; +import { useScratchCard } from '../hooks/useScratchCard'; + +const PRIZES = ['$5', '$20', '$50', '$500']; + +type Ticket = { + symbols: readonly [PrizeSymbol, PrizeSymbol, PrizeSymbol, PrizeSymbol]; + amounts: string[]; + bonus: string; + serial: string; + win: boolean; +}; + +function pick(list: readonly T[]): T { + return list[Math.floor(Math.random() * list.length)]!; +} + +function makeTicket(): Ticket { + const win = Math.random() < 0.6; + const main = pick([0, 1, 2, 3]); + const others = ([0, 1, 2, 3] as PrizeSymbol[]).filter((s) => s !== main); + const row: PrizeSymbol[] = win + ? [main, main, main] + : [main, main, pick(others)].sort(() => Math.random() - 0.5); + const prize = pick(PRIZES); + const serial = Array.from({ length: 3 }, () => + Math.floor(1000 + Math.random() * 9000) + ).join(' '); + return { + symbols: [row[0]!, row[1]!, row[2]!, pick([0, 1, 2, 3])], + amounts: win ? [prize, prize, prize] : row.map(() => pick(PRIZES)), + bonus: pick(['$2', '$10', '$25']), + serial, + win, + }; +} + +/** + * A scratch card. + * + * Drag a finger over the silver to scratch it off: the latex tears away + * along the stroke with flaky edges, crumbs tumble off, and it rasps louder + * the faster you go. Each prize box clears itself once most of it is + * scratched, with a sparkle and a haptic tick. Tilt the phone to move the + * foil's sheen and the holographic symbols' rainbow. + */ +export default function ScratchOffScreen() { + const insets = useSafeAreaInsets(); + const { width } = useWindowDimensions(); + const scale = (width * 0.92) / CARD_W; + const { paramsSynchronizable, flakeState, stroke, lift, reset, revealed } = + useScratchCard(); + const [ticket, setTicket] = useState(makeTicket); + + const newCard = useCallback(() => { + reset(); + setTicket(makeTicket()); + }, [reset]); + + const scratch = Gesture.Pan() + .minDistance(0) + .runOnJS(true) + .onBegin((e) => stroke(e.x, e.y, true)) + .onUpdate((e) => stroke(e.x, e.y, false)) + .onFinalize(lift); + + const done = revealed >= 3; + + return ( + + + + + + + + Clover Cash + + Match three symbols, win that prize. + + + + $5 + + + + + + {AREAS.map((a, i) => ( + + + {i === 3 ? ticket.bonus : ticket.amounts[i]} + + + ))} + + + + + + + No. {ticket.serial} + Void if altered + + + + + + {ticket.win ? `You won ${ticket.amounts[0]}` : 'Not this time'} + + + [ + styles.button, + { bottom: insets.bottom + 24 }, + pressed && styles.buttonPressed, + ]} + > + New card + + + ); +} + +const GREEN = '#1d4a37'; +const CARD_W = PLAY_W + 44; +const SERIF = 'Georgia'; + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#14171a', + alignItems: 'center', + justifyContent: 'center', + paddingBottom: 90, + }, + card: { + width: CARD_W, + borderRadius: 18, + backgroundColor: '#f2ead8', + padding: 7, + shadowColor: '#000', + shadowOpacity: 0.5, + shadowRadius: 24, + shadowOffset: { width: 0, height: 14 }, + }, + frame: { + borderRadius: 13, + borderWidth: 1.5, + borderColor: GREEN, + paddingHorizontal: 14, + paddingTop: 18, + paddingBottom: 14, + }, + head: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 18, + }, + title: { + fontFamily: SERIF, + fontSize: 32, + fontWeight: '700', + color: GREEN, + letterSpacing: -0.5, + }, + subtitle: { + fontFamily: SERIF, + fontStyle: 'italic', + fontSize: 14, + color: '#55635a', + marginTop: 4, + }, + price: { + width: 52, + height: 52, + borderRadius: 26, + backgroundColor: '#b8892b', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: '#e2c27a', + }, + priceText: { + fontFamily: SERIF, + fontSize: 20, + fontWeight: '700', + color: '#fffaf0', + }, + play: { + width: PLAY_W, + height: PLAY_H, + }, + panel: { + position: 'absolute', + borderRadius: 10, + backgroundColor: '#fbf7ee', + borderWidth: 1, + borderColor: 'rgba(29, 74, 55, 0.25)', + alignItems: 'center', + justifyContent: 'flex-end', + paddingBottom: 8, + }, + bonusPanel: { + alignItems: 'flex-end', + justifyContent: 'center', + paddingBottom: 0, + paddingRight: 26, + }, + amount: { + fontFamily: SERIF, + fontSize: 19, + fontWeight: '700', + color: GREEN, + }, + bonusAmount: { + fontFamily: SERIF, + fontSize: 28, + fontWeight: '700', + color: GREEN, + }, + coating: { + position: 'absolute', + left: 0, + top: 0, + width: PLAY_W, + height: PLAY_H, + }, + foot: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 14, + }, + fine: { + fontSize: 10, + color: '#7b847d', + letterSpacing: 0.4, + }, + result: { + marginTop: 36, + fontSize: 20, + fontWeight: '600', + color: '#f2ead8', + }, + hidden: { + opacity: 0, + }, + button: { + position: 'absolute', + paddingHorizontal: 26, + paddingVertical: 14, + borderRadius: 26, + backgroundColor: 'rgba(255,255,255,0.12)', + }, + buttonPressed: { + opacity: 0.6, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/example/src/screens/ScrollReactiveScreen.tsx b/example/src/screens/ScrollReactiveScreen.tsx index 94bb6e8..a5059be 100644 --- a/example/src/screens/ScrollReactiveScreen.tsx +++ b/example/src/screens/ScrollReactiveScreen.tsx @@ -12,7 +12,6 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useParamsSynchronizable } from 'react-native-effects'; import ScrollReactive from '../components/ScrollReactive'; -import { BackButton } from '../components/BackButton'; // A serif face does most of the work of escaping the generic glassy-SaaS look. // System serifs only — no font loading required. @@ -58,8 +57,6 @@ export default function ScrollReactiveScreen() { [setParamsSynchronizable] ); - console.log('render ScrollReactiveScreen'); // Should only log once per page, never every frame. - return ( - - - - (); + const insets = useSafeAreaInsets(); + const [query, setQuery] = useState(''); + const results = useMemo(() => searchEffects(query), [query]); + + const openEffect = useCallback( + (effect: Effect) => navigation.navigate(effect.screen as never), + [navigation] + ); + + const renderItem = useCallback( + ({ item }: { item: Effect }) => ( + + ), + [openEffect] + ); + + return ( + + + + + + + + navigation.goBack()} + hitSlop={8} + > + Cancel + + + No effects match “{query}”. + } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + searchRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 14, + paddingHorizontal: SCREEN_MARGIN, + paddingBottom: 10, + }, + field: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + minHeight: 40, + paddingHorizontal: 12, + borderRadius: 12, + backgroundColor: colors.surface, + }, + input: { + flex: 1, + paddingVertical: 8, + fontSize: 17, + color: colors.text, + }, + cancel: { + fontSize: 17, + color: colors.text, + }, + empty: { + paddingTop: 48, + paddingHorizontal: SCREEN_MARGIN, + fontSize: 16, + textAlign: 'center', + color: colors.secondary, + }, +}); diff --git a/example/src/screens/SilkScreen.tsx b/example/src/screens/SilkScreen.tsx index f0bbc8e..68832f8 100644 --- a/example/src/screens/SilkScreen.tsx +++ b/example/src/screens/SilkScreen.tsx @@ -1,24 +1,11 @@ -import { View, Text, StyleSheet, StatusBar } from 'react-native'; +import { StatusBar, StyleSheet, View } from 'react-native'; import { Silk } from 'react-native-effects'; -import { Header } from '../components/Header'; export default function SilkScreen() { return ( - - - -
- - - - Silk - - A smooth flowing silk fabric pattern with subtle noise - - - + ); } @@ -28,32 +15,4 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#000', }, - content: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 40, - }, - infoCard: { - backgroundColor: 'rgba(0, 0, 0, 0.4)', - borderRadius: 24, - padding: 32, - alignItems: 'center', - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.2)', - }, - title: { - fontSize: 32, - fontWeight: '800', - color: '#fff', - marginBottom: 12, - textAlign: 'center', - letterSpacing: -0.5, - }, - subtitle: { - fontSize: 16, - color: 'rgba(255, 255, 255, 0.8)', - textAlign: 'center', - fontWeight: '400', - }, }); diff --git a/example/src/screens/SiriOrbScreen.tsx b/example/src/screens/SiriOrbScreen.tsx index b0a9b86..0ea4051 100644 --- a/example/src/screens/SiriOrbScreen.tsx +++ b/example/src/screens/SiriOrbScreen.tsx @@ -1,18 +1,8 @@ -import { - View, - Text, - StyleSheet, - StatusBar, - Image, - TouchableOpacity, -} from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { View, StyleSheet, StatusBar, Image } from 'react-native'; import SiriOrb from '../components/SiriOrb'; import SiriEdgeGlow from '../components/SiriEdgeGlow'; export default function SiriOrbScreen() { - const navigation = useNavigation(); - return ( - navigation.goBack()} - > - {'← Back'} - - - - - Siri Orb - - A living sphere with swirling colors, specular highlights, and a - soft outer glow - - - + @@ -54,48 +29,8 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#000', }, - backButton: { - position: 'absolute', - top: 54, - left: 16, - zIndex: 10, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 20, - }, - backText: { - color: '#fff', - fontSize: 15, - fontWeight: '600', - }, - content: { + spacer: { flex: 1, - justifyContent: 'flex-end', - alignItems: 'flex-end', - paddingHorizontal: 40, - }, - infoCard: { - backgroundColor: 'rgba(0, 0, 0, 0.4)', - borderRadius: 24, - padding: 32, - alignItems: 'center', - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.2)', - }, - title: { - fontSize: 32, - fontWeight: '800', - color: '#fff', - marginBottom: 12, - textAlign: 'center', - letterSpacing: -0.5, - }, - subtitle: { - fontSize: 16, - color: 'rgba(255, 255, 255, 0.8)', - textAlign: 'center', - fontWeight: '400', }, backgroundImage: { ...StyleSheet.absoluteFill, diff --git a/example/src/screens/ThanosScreen.tsx b/example/src/screens/ThanosScreen.tsx index 6b98e34..be558ed 100644 --- a/example/src/screens/ThanosScreen.tsx +++ b/example/src/screens/ThanosScreen.tsx @@ -8,16 +8,13 @@ import Animated, { withSequence, withTiming, } from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useParamsSynchronizable } from 'react-native-effects'; import { DISSOLVES } from '../components/dissolves'; -import { BackButton } from '../components/BackButton'; const SNAP_MS = 1700; const REFORM_MS = 900; export default function ThanosScreen() { - const insets = useSafeAreaInsets(); const [gone, setGone] = useState(false); const [mode, setMode] = useState(0); const Active = DISSOLVES[mode]!.Component; @@ -105,7 +102,7 @@ export default function ThanosScreen() { STATUS RENDERED - tap to snap ✦ + Tap to snap @@ -130,18 +127,9 @@ export default function ThanosScreen() { - - {gone ? '↺ Restore' : 'Snap ✦'} - + {gone ? 'Restore' : 'Snap'} - - - - ); } @@ -157,11 +145,6 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingHorizontal: 22, }, - backWrap: { - position: 'absolute', - left: 22, - zIndex: 100, - }, cardWrap: { width: '100%', aspectRatio: 1.586, diff --git a/example/src/screens/TouchFieldScreen.tsx b/example/src/screens/TouchFieldScreen.tsx index b94f788..ffc69bb 100644 --- a/example/src/screens/TouchFieldScreen.tsx +++ b/example/src/screens/TouchFieldScreen.tsx @@ -1,7 +1,6 @@ import { View, Text, StyleSheet, StatusBar } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import TouchField from '../components/TouchField'; -import { BackButton } from '../components/BackButton'; export default function TouchFieldScreen() { const insets = useSafeAreaInsets(); @@ -15,14 +14,10 @@ export default function TouchFieldScreen() { style={StyleSheet.absoluteFill} /> - + {/* Keeps the left edge free of the pan so the back swipe still works. */} + - - - + {/* Hint pill. `pointerEvents="none"` so it never eats the drag gesture. */} - Drag anywhere ✦ + Drag anywhere @@ -42,10 +37,12 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#000', }, - backWrap: { + backSwipeEdge: { position: 'absolute', - left: 22, - zIndex: 100, + top: 0, + bottom: 0, + left: 0, + width: 24, }, hintWrap: { position: 'absolute', diff --git a/example/src/screens/WalletPayScreen.tsx b/example/src/screens/WalletPayScreen.tsx new file mode 100644 index 0000000..cd70436 --- /dev/null +++ b/example/src/screens/WalletPayScreen.tsx @@ -0,0 +1,356 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + StatusBar, + StyleSheet, + Text, + useWindowDimensions, + Vibration, + View, +} from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useParamsSynchronizable } from 'react-native-effects'; +import { + CardArt, + StackCard, + type StackCardSpec, +} from '../components/wallet/CardArt'; +import CardSurface, { CARD_RADIUS } from '../components/wallet/CardSurface'; +import PayGlyph from '../components/wallet/PayGlyph'; +import { playChime } from '../components/wallet/chime'; +import { useHoldTilt } from '../hooks/useHoldTilt'; + +const CARD_ASPECT = 1.586; +const GLYPH_SIZE = 84; + +/** The flow, in seconds from the double click. */ +const FLOW = { + faceIn: [0.15, 0.35], + nod: [0.35, 0.8], + ringIn: [0.75, 0.95], + checkDraw: [1.2, 1.45], + faceOut: [1.7, 1.85], + readerIn: [1.85, 2.1], + paid: 4.4, + doneIn: [4.4, 4.75], + settle: [4.6, 5.2], + end: 7.2, + doneOut: [7.2, 7.5], +} as const; + +type Stage = 'idle' | 'auth' | 'reader' | 'done'; + +type DebugGlobal = { __walletSeek?: (t: number | null) => void }; + +function ramp(t: number, [a, b]: readonly [number, number]): number { + return Math.max(0, Math.min(1, (t - a) / (b - a))); +} + +function easeOut(x: number): number { + return 1 - (1 - x) * (1 - x) * (1 - x); +} + +function stageAt(t: number): Stage { + if (t < FLOW.readerIn[0]) { + return 'auth'; + } + if (t < FLOW.paid) { + return 'reader'; + } + if (t < FLOW.doneOut[1]) { + return 'done'; + } + return 'idle'; +} + +const STAGE_LABEL: Record = { + idle: '', + auth: 'Pay with Face ID', + reader: 'Hold Near Reader', + done: 'Done', +}; + +/** + * Apple Pay, the double-click-to-pay sheet. + * + * A brushed titanium card waits at the top, the other cards stacked at the + * bottom, and the side-button hint. Double-tap anywhere (standing in for the + * double click): the card lifts and a glint runs over it, Face ID scans and + * checks, Hold Near Reader pulses, then the payment lands with a haptic, a + * chime and the blue Done check, and the card settles back. Tilting the + * phone slides the light across the brushed metal. + * + * Dev builds: `__walletTilt(x, y)` stands in for the motion sensor and + * `__walletSeek(seconds)` freezes the flow at a moment (`null` resumes). + */ +export default function WalletPayScreen() { + const { width, height } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const tilt = useHoldTilt('__walletTilt'); + const card = useParamsSynchronizable([0, 0, 0, 0]); + const glyph = useParamsSynchronizable([0, 0, 0, 0, 0, 0, 0, 0]); + const startedAt = useRef(null); + const frozenAt = useRef(null); + const [stage, setStage] = useState('idle'); + const lift = useSharedValue(0); + const nudge = useSharedValue(0); + + const cardWidth = width - 32; + const cardHeight = cardWidth / CARD_ASPECT; + const cardTop = insets.top + 68; + + useEffect(() => { + nudge.value = withRepeat( + withSequence( + withTiming(-6, { duration: 420, easing: Easing.out(Easing.quad) }), + withTiming(0, { duration: 420, easing: Easing.in(Easing.quad) }), + withTiming(0, { duration: 700 }) + ), + -1 + ); + }, [nudge]); + + const setCard = card.setParamsSynchronizable; + const setGlyph = glyph.setParamsSynchronizable; + + useEffect(() => { + let frame = 0; + let spin = 0; + let last = Date.now(); + let shown: Stage = 'idle'; + let paid = false; + const step = () => { + const now = Date.now(); + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + spin += dt * 6; + const t0 = startedAt.current; + const t = frozenAt.current ?? (t0 === null ? -1 : (now - t0) / 1000); + const running = t >= 0 && t < FLOW.doneOut[1]; + + const glint = running ? ramp(t, [0.05, 0.95]) : 0; + const up = running + ? easeOut(ramp(t, [0, 0.35])) * (1 - ramp(t, FLOW.settle)) + : 0; + lift.value = up; + setCard(tilt.current.x, tilt.current.y, glint, up); + + if (running) { + const faceAmt = ramp(t, FLOW.faceIn) * (1 - ramp(t, FLOW.faceOut)); + const ring = ramp(t, FLOW.ringIn); + const check = + ramp(t, FLOW.checkDraw) - ramp(t, [FLOW.faceOut[0], 1.82]); + const reader = + ramp(t, FLOW.readerIn) * (1 - ramp(t, [FLOW.paid, FLOW.paid + 0.2])); + const done = ramp(t, FLOW.doneIn) * (1 - ramp(t, FLOW.doneOut)); + const nod = + Math.sin((t - FLOW.nod[0]) * 10) * + ramp(t, [FLOW.nod[0], FLOW.nod[0] + 0.1]) * + (1 - ramp(t, [FLOW.nod[1] - 0.1, FLOW.nod[1]])); + setGlyph(faceAmt, ring, Math.max(0, check), reader, done, spin, nod, 0); + if (!paid && t >= FLOW.paid) { + paid = true; + Vibration.vibrate(40); + playChime(); + } + } else { + setGlyph(0, 0, 0, 0, 0, spin, 0, 0); + startedAt.current = null; + } + + const next = running ? stageAt(t) : 'idle'; + if (next !== shown) { + shown = next; + if (next === 'auth') { + paid = false; + } + setStage(next); + } + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + return () => cancelAnimationFrame(frame); + }, [lift, setCard, setGlyph, tilt]); + + useEffect(() => { + if (!__DEV__) { + return; + } + (globalThis as DebugGlobal).__walletSeek = (t) => { + frozenAt.current = t; + }; + return () => { + delete (globalThis as DebugGlobal).__walletSeek; + }; + }, []); + + const pay = useCallback(() => { + if (startedAt.current === null) { + startedAt.current = Date.now(); + } + }, []); + + const doubleTap = Gesture.Tap() + .numberOfTaps(2) + .maxDelay(350) + .runOnJS(true) + .onEnd(pay); + + const cardStyle = useAnimatedStyle(() => ({ + transform: [ + { translateY: -6 * lift.value }, + { scale: 1 + 0.035 * lift.value }, + ], + shadowOpacity: 0.35 + 0.35 * lift.value, + shadowRadius: 18 + 18 * lift.value, + })); + + const hintStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: nudge.value }], + })); + + const label = STAGE_LABEL[stage]; + const sideTop = cardTop + cardHeight + 28; + const sideHeight = height * 0.075; + + return ( + + + + + + {STACK.map((spec, i) => ( + + ))} + + + + + + + + + + {stage === 'idle' && ( + + + {'Double Click\nto Pay'} + + + + )} + + + + {label} + + + + ); +} + +const STACK: StackCardSpec[] = [ + { name: 'coral', color: '#f0604a', ink: '#fff6f2', mark: 'VISA' }, + { name: 'Northwind Bank', color: '#1d3f8f', ink: '#e8eeff', mark: 'VISA' }, + { name: 'Savings', color: '#e4e4e8', ink: '#2b2b30', mark: '●●' }, +]; + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, + stack: { + position: 'absolute', + left: 16, + top: 0, + bottom: 0, + }, + card: { + position: 'absolute', + left: 16, + borderRadius: CARD_RADIUS, + backgroundColor: '#0e0f11', + shadowColor: '#000', + shadowOffset: { width: 0, height: 12 }, + }, + cardClip: { + flex: 1, + borderRadius: CARD_RADIUS, + overflow: 'hidden', + }, + side: { + position: 'absolute', + right: 0, + justifyContent: 'center', + }, + sideRow: { + flexDirection: 'row', + alignItems: 'center', + }, + sideText: { + color: '#fff', + fontSize: 17, + fontWeight: '600', + textAlign: 'right', + marginRight: 14, + lineHeight: 21, + }, + sidePill: { + width: 6, + borderTopLeftRadius: 3, + borderBottomLeftRadius: 3, + backgroundColor: '#fff', + }, + status: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + }, + glyph: { + width: GLYPH_SIZE, + height: GLYPH_SIZE, + }, + label: { + marginTop: 16, + color: 'rgba(235, 235, 245, 0.62)', + fontSize: 19, + fontWeight: '400', + letterSpacing: -0.2, + minHeight: 26, + }, +}); diff --git a/example/src/screens/WeatherScreen.tsx b/example/src/screens/WeatherScreen.tsx index 55cf69b..5e46680 100644 --- a/example/src/screens/WeatherScreen.tsx +++ b/example/src/screens/WeatherScreen.tsx @@ -18,7 +18,6 @@ import Animated, { clamp, } from 'react-native-reanimated'; import type { LayoutChangeEvent } from 'react-native'; -import { BackButton } from '../components/BackButton'; import RainSplash from '../components/RainSplash'; import CrossfadeBackground from '../components/CrossfadeBackground'; import { WEATHER_THEMES, type WeatherScenario } from '../config/weatherThemes'; @@ -560,10 +559,6 @@ export default function WeatherScreen() { {/* ═══ Fixed header — outside scroll view ═══ */} - - - - Kraków @@ -747,9 +742,6 @@ const styles = StyleSheet.create({ paddingTop: 60, paddingHorizontal: 20, }, - backButtonRow: { - marginBottom: 6, - }, scenarioRow: { position: 'absolute', bottom: 40, diff --git a/example/src/types.ts b/example/src/types.ts index 982b45b..7f059fa 100644 --- a/example/src/types.ts +++ b/example/src/types.ts @@ -2,10 +2,9 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; export type RootStackParamList = { Home: undefined; + Search: undefined; + About: undefined; CircularGradientList: undefined; - CircularGradientAnimated: undefined; - CircularGradientStatic: undefined; - CircularGradientInteractive: undefined; LinearGradientList: undefined; LinearGradientAnimated: undefined; LinearGradientStatic: undefined; @@ -15,9 +14,6 @@ export type RootStackParamList = { CampfireStatic: undefined; CalicoSwirlStatic: undefined; AuroraStatic: undefined; - DesertStatic: undefined; - HoloStatic: undefined; - GlitterStatic: undefined; SiriOrbStatic: undefined; HoloCardExample: undefined; WeatherExample: undefined; @@ -39,21 +35,22 @@ export type RootStackParamList = { FoamLabExample: undefined; VoiceDeleteExample: undefined; Magic8BallExample: undefined; - CigaretteExample: undefined; DandelionExample: undefined; HourglassExample: undefined; TelegramSpoilerExample: undefined; IMessageExample: undefined; + CrackedScreenExample: undefined; + DuoCrackgateExample: undefined; + PolaroidExample: undefined; + ScratchOffExample: undefined; + BubbleWrapExample: undefined; + LavaLampExample: undefined; + FerrofluidExample: undefined; + CrtOffExample: undefined; + FlashlightExample: undefined; + WalletPayExample: undefined; + NameDropExample: undefined; }; export type HomeScreenNavigationProp = NativeStackNavigationProp; - -export type ExampleCategory = { - id: string; - title: string; - description: string; - screen: keyof RootStackParamList; - color: string; - image: number; -}; diff --git a/package.json b/package.json index 9f1eb64..1038781 100644 --- a/package.json +++ b/package.json @@ -69,9 +69,9 @@ "@eslint/compat": "^2.1.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^9.39.4", - "@react-native/babel-preset": "0.88.0", - "@react-native/eslint-config": "0.88.0", - "@react-native/jest-preset": "0.88.0", + "@react-native/babel-preset": "0.88.1", + "@react-native/eslint-config": "0.88.1", + "@react-native/jest-preset": "0.88.1", "@release-it/conventional-changelog": "^11.0.1", "@types/jest": "^29.5.14", "@types/react": "^19.3.0", @@ -85,7 +85,7 @@ "lefthook": "^2.1.9", "prettier": "^3.8.3", "react": "19.3.0", - "react-native": "0.88.0", + "react-native": "0.88.1", "react-native-builder-bob": "^0.43.2", "react-native-gesture-handler": "3.3.0", "react-native-reanimated": "4.7.1", diff --git a/yarn.lock b/yarn.lock index 9d0055e..8892171 100644 --- a/yarn.lock +++ b/yarn.lock @@ -275,6 +275,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-string-parser@npm:^7.29.11": + version: 7.29.11 + resolution: "@babel/helper-string-parser@npm:7.29.11" + checksum: 10c0/673a309056c593af89296464ff149d0478889e3f5a577add33f1d4d38c54e44d0381cf232f744965ee71534489ebaa4d5c45ba5f87e1d2ce9bf54a67d2d0d72d + languageName: node + linkType: hard + "@babel/helper-string-parser@npm:^7.29.7": version: 7.29.7 resolution: "@babel/helper-string-parser@npm:7.29.7" @@ -328,6 +335,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.20.0": + version: 7.29.12 + resolution: "@babel/parser@npm:7.29.12" + dependencies: + "@babel/types": "npm:^7.29.11" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/5be1bda59715d21a060ed5a49c2f6396058f3e5ec14c218df4090eef053e4bf9af2e3bebbf1e862bdc4c2d83b028fcf315dbaffb1a72da432241f57dcfdaa4f4 + languageName: node + linkType: hard + "@babel/parser@npm:^7.29.8, @babel/parser@npm:^7.29.9": version: 7.29.9 resolution: "@babel/parser@npm:7.29.9" @@ -1616,6 +1634,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.29.11": + version: 7.29.11 + resolution: "@babel/types@npm:7.29.11" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.11" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10c0/d0d4edd2fbff0a8e79af1fea3c3c056de86bc0e01ed734d00062287638379933a232f087959c6eb67365c2f4fb44d57b88793cd1e87f0aad568f1c8b3b94da80 + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" @@ -1937,38 +1965,45 @@ __metadata: languageName: node linkType: hard -"@expo/cli@npm:^58.1.13": - version: 58.1.13 - resolution: "@expo/cli@npm:58.1.13" +"@expo-google-fonts/material-symbols@npm:^0.4.1": + version: 0.4.52 + resolution: "@expo-google-fonts/material-symbols@npm:0.4.52" + checksum: 10c0/8b6de234164a8eae44f9497dc903536a12f9bb3ffd50c6949ea5a5636d3b06c2885d32ecc5476e0ec7075c6cfecaba02b02e5d382109b7f3820b188726578477 + languageName: node + linkType: hard + +"@expo/cli@npm:^58.1.17": + version: 58.1.17 + resolution: "@expo/cli@npm:58.1.17" dependencies: 2g: "npm:^1.0.1" "@expo/code-signing-certificates": "npm:^0.0.7" - "@expo/config": "npm:~58.0.6" - "@expo/config-plugins": "npm:~58.0.9" + "@expo/config": "npm:~58.0.9" + "@expo/config-plugins": "npm:~58.0.11" "@expo/devcert": "npm:^1.2.2" - "@expo/env": "npm:~2.5.2" - "@expo/image-utils": "npm:^0.12.5" - "@expo/inline-modules": "npm:^0.2.6" + "@expo/env": "npm:~2.5.3" + "@expo/image-utils": "npm:^0.12.6" + "@expo/inline-modules": "npm:^0.2.8" "@expo/json-file": "npm:^11.2.1" - "@expo/log-box": "npm:^58.0.12" + "@expo/log-box": "npm:^58.0.13" "@expo/log-box-utils": "npm:^58.0.2" "@expo/metro": "npm:~58.0.0" - "@expo/metro-config": "npm:58.0.15" + "@expo/metro-config": "npm:58.0.19" "@expo/metro-file-map": "npm:^58.0.5" "@expo/osascript": "npm:^2.8.1" "@expo/package-manager": "npm:^1.14.2" "@expo/platforms": "npm:^58.0.1" "@expo/plist": "npm:^0.10.1" - "@expo/prebuild-config": "npm:^58.0.14" - "@expo/require-utils": "npm:^58.0.4" - "@expo/router-server": "npm:^58.0.15" + "@expo/prebuild-config": "npm:^58.0.17" + "@expo/require-utils": "npm:^58.0.5" + "@expo/router-server": "npm:^58.0.19" "@expo/schema-utils": "npm:^58.0.2" "@expo/spawn-async": "npm:^1.8.0" "@expo/ws-tunnel": "npm:^2.0.1" "@expo/xcpretty": "npm:^4.4.6" - "@react-native/asset-utils": "npm:0.88.0" - "@react-native/dev-middleware": "npm:0.88.0" - "@react-native/js-polyfills": "npm:0.88.0" + "@react-native/asset-utils": "npm:0.88.1" + "@react-native/dev-middleware": "npm:0.88.1" + "@react-native/js-polyfills": "npm:0.88.1" accepts: "npm:^1.3.8" agent-cli-detector: "npm:0.1.7" arg: "npm:^5.0.2" @@ -1979,7 +2014,7 @@ __metadata: compression: "npm:^1.7.4" connect: "npm:^3.7.0" dnssd-advertise: "npm:^1.1.6" - expo-server: "npm:^58.0.5" + expo-server: "npm:^58.0.6" fetch-nodeshim: "npm:^0.4.10" getenv: "npm:^2.0.0" glob: "npm:^13.0.0" @@ -2015,7 +2050,7 @@ __metadata: optional: true bin: expo-internal: bin/cli.js - checksum: 10c0/fd0b5ce1f46da8095723a445a3dde6c582ddc40f4c5da12dacd83b9fb9d9c0bbf547d942a97c6a3313ff8d9850b586ab1cbfc20917ea044c878fa430ca90fe49 + checksum: 10c0/fc1ef1ba25d18b47221964b790268037c37058929fe1f91673c2fe7ca5c7514329913c122ccfb79b7124c22c59052af8af3aa4d6eff7414dc8dd6aa784d5f757 languageName: node linkType: hard @@ -2028,14 +2063,14 @@ __metadata: languageName: node linkType: hard -"@expo/config-plugins@npm:~58.0.9": - version: 58.0.9 - resolution: "@expo/config-plugins@npm:58.0.9" +"@expo/config-plugins@npm:~58.0.11": + version: 58.0.11 + resolution: "@expo/config-plugins@npm:58.0.11" dependencies: "@expo/config-types": "npm:^58.0.3" "@expo/json-file": "npm:~11.2.1" "@expo/plist": "npm:^0.10.1" - "@expo/require-utils": "npm:^58.0.4" + "@expo/require-utils": "npm:^58.0.5" "@expo/sdk-runtime-versions": "npm:^1.0.1" chalk: "npm:^4.1.2" debug: "npm:^4.3.5" @@ -2043,9 +2078,9 @@ __metadata: glob: "npm:^13.0.0" semver: "npm:^7.7.4" slugify: "npm:^1.6.6" - xcode: "npm:^3.0.1" + xcode: "npm:^4.0.0" xml2js: "npm:0.6.0" - checksum: 10c0/1b9884802c06edd24376c349a345d378f7fbb5d50ae6d6a62f3cb7914da3beed315f1b3394c6cc93d2a22c1691f069213fbfc533da6f0b19428c2d9a86c7227f + checksum: 10c0/a90bd2b166de6e6bcac8d94e61dd398a0fd1a20630460c59550243e2f3b8d6f1491e44dbcf202ff93e24ab8b8a6e956a443b9c03eb4019d485314d3b649b664a languageName: node linkType: hard @@ -2056,22 +2091,22 @@ __metadata: languageName: node linkType: hard -"@expo/config@npm:~58.0.6": - version: 58.0.6 - resolution: "@expo/config@npm:58.0.6" +"@expo/config@npm:~58.0.9": + version: 58.0.9 + resolution: "@expo/config@npm:58.0.9" dependencies: - "@expo/config-plugins": "npm:~58.0.9" + "@expo/config-plugins": "npm:~58.0.11" "@expo/config-types": "npm:^58.0.3" "@expo/json-file": "npm:^11.2.1" "@expo/platforms": "npm:^58.0.1" - "@expo/require-utils": "npm:^58.0.4" + "@expo/require-utils": "npm:^58.0.5" deepmerge: "npm:^4.3.1" getenv: "npm:^2.0.0" glob: "npm:^13.0.0" resolve-workspace-root: "npm:^2.0.0" semver: "npm:^7.7.4" slugify: "npm:^1.3.4" - checksum: 10c0/b3faf6ce21d637b52d0446e707659ed3d7bcbc002000682bc741d411bc9aff2593a02c3724c86afd677e950de7e9191b7eb032f3c2dd9651a2876f6c77326fdb + checksum: 10c0/6f4d940910406b50bd1c22ecd950c71a13805a4baecf62ce4c38198aa39eb45bcd4635aa3bebaea0d7bdfb8b174e5147fa2d3b6f60f7ea6ff58b25db8fa3b56a languageName: node linkType: hard @@ -2116,22 +2151,22 @@ __metadata: languageName: node linkType: hard -"@expo/env@npm:^2.5.2, @expo/env@npm:~2.5.2": - version: 2.5.2 - resolution: "@expo/env@npm:2.5.2" +"@expo/env@npm:^2.5.3, @expo/env@npm:~2.5.3": + version: 2.5.3 + resolution: "@expo/env@npm:2.5.3" dependencies: chalk: "npm:^4.0.0" debug: "npm:^4.3.4" getenv: "npm:^2.0.0" - checksum: 10c0/674d536f510f899e377f90e0db852cea7cb945ee02e3d0eca6d3ec4e2fdfb6db9690e92ebb9199bbeeb00a1ca7177067ee7173a9146b5bcfb54ae533d84d4fc4 + checksum: 10c0/6dae45e4d1b8ea0e9dc4ad9e1c80f1170dba4730824b92c455e65846e56a5da08639adf0992a35bdcb0d7c5b6b06732816e08fe4d14fd76062eaf29ce2ebcd0a languageName: node linkType: hard -"@expo/fingerprint@npm:^0.21.5": - version: 0.21.5 - resolution: "@expo/fingerprint@npm:0.21.5" +"@expo/fingerprint@npm:^0.21.7": + version: 0.21.7 + resolution: "@expo/fingerprint@npm:0.21.7" dependencies: - "@expo/env": "npm:^2.5.2" + "@expo/env": "npm:^2.5.3" "@expo/spawn-async": "npm:^1.8.0" arg: "npm:^5.0.2" chalk: "npm:^4.1.2" @@ -2144,31 +2179,31 @@ __metadata: semver: "npm:^7.7.4" bin: fingerprint: bin/cli.js - checksum: 10c0/9e0a9c22feb64d53afc6e8dc563ce6d16aa6c2eb718910aa76a1110f7aa10180c19a95d8899c1e9798e7117a16d7b6bbabf603b4a79bd7f094606d8cfd814213 + checksum: 10c0/5a785bba252444916e3bb12b03c342f3ae5849ff4efa449c851a270b35c2d1f332f9fbc13038b63f0f531580bf0f51ffbb57a57d9a50de779678908792ed6fea languageName: node linkType: hard -"@expo/image-utils@npm:^0.12.5": - version: 0.12.5 - resolution: "@expo/image-utils@npm:0.12.5" +"@expo/image-utils@npm:^0.12.6": + version: 0.12.6 + resolution: "@expo/image-utils@npm:0.12.6" dependencies: - "@expo/require-utils": "npm:^58.0.4" + "@expo/require-utils": "npm:^58.0.5" "@expo/spawn-async": "npm:^1.8.0" chalk: "npm:^4.0.0" getenv: "npm:^2.0.0" jimp-compact: "npm:0.16.1" parse-png: "npm:^2.1.0" semver: "npm:^7.7.4" - checksum: 10c0/66f97b203e15a51c0bc7331ebb59940b7ec5aef116d80da4afedd04c2a72654adc302538e16e3b4050fc5acf5c81d905c5489a053bce96d6c12b5875b7c06f46 + checksum: 10c0/9f3aaea3ac2dfce70f3ddebacfc593491c45e3b19a2d00488704122d6702f86c36cbadd9aaec219d052285f842b5e4417377622a1306e45eb58aa6991bf97f2b languageName: node linkType: hard -"@expo/inline-modules@npm:^0.2.6": - version: 0.2.6 - resolution: "@expo/inline-modules@npm:0.2.6" +"@expo/inline-modules@npm:^0.2.8": + version: 0.2.8 + resolution: "@expo/inline-modules@npm:0.2.8" dependencies: - "@expo/config-plugins": "npm:~58.0.9" - checksum: 10c0/b2a67c95a868e19c1589586a916e066a590087dd2889309670fbe28c25a3605cdb091beb8c6e3c227b9c0f784579b2515b08ccbb5c71cc27aa265131ecb33cbb + "@expo/config-plugins": "npm:~58.0.11" + checksum: 10c0/92093b38f77ed3993195e3ec7b2e47af2f36dee76e3e281492eda49488cab887b42303c3cc517883586138ebc0efa8d0ef8c4307986d3558584a65229272be1d languageName: node linkType: hard @@ -2182,13 +2217,13 @@ __metadata: languageName: node linkType: hard -"@expo/local-build-cache-provider@npm:^58.0.6": - version: 58.0.6 - resolution: "@expo/local-build-cache-provider@npm:58.0.6" +"@expo/local-build-cache-provider@npm:^58.0.9": + version: 58.0.9 + resolution: "@expo/local-build-cache-provider@npm:58.0.9" dependencies: - "@expo/config": "npm:~58.0.6" + "@expo/config": "npm:~58.0.9" chalk: "npm:^4.1.2" - checksum: 10c0/cd0d196f3ff3202cc1930d70843aa8d1b6c2026070309c448470b6ae0ce99a0adb739ad723fc13bc7ad536d2c7a10f7889edabf36d6d97d93197ce9aedd037d5 + checksum: 10c0/1704a1e0e834412b34a774028f56dd822c7355c1548f361c800fc9db6050d976d5be4bc50ff4c4d2a5f36aa3d19115f56985c452d9755007ed3066cf9398af66 languageName: node linkType: hard @@ -2199,9 +2234,9 @@ __metadata: languageName: node linkType: hard -"@expo/log-box@npm:^58.0.12": - version: 58.0.12 - resolution: "@expo/log-box@npm:58.0.12" +"@expo/log-box@npm:^58.0.13": + version: 58.0.13 + resolution: "@expo/log-box@npm:58.0.13" dependencies: "@expo/dom-webview": "npm:^58.0.2" "@expo/log-box-utils": "npm:^58.0.2" @@ -2215,30 +2250,30 @@ __metadata: peerDependenciesMeta: expo: optional: true - checksum: 10c0/fba1cfd15f87899656f11bc5528dbb07d750bab5811b0c74700dbd9c9492ba56632e7f8c4e42b93a4a9d5e436b4bcd60569569bcd0b09984f391131edd426160 + checksum: 10c0/f0bf8805ec6c78da0e025f74afa786e899d71a39c66ea0cbcfbec0f7b8e0e113ef7794567295e23b529fbc42f2b9a4749bf9abcf8a521dcdcaacdd3bd2c177cd languageName: node linkType: hard -"@expo/metro-config@npm:58.0.15, @expo/metro-config@npm:~58.0.15": - version: 58.0.15 - resolution: "@expo/metro-config@npm:58.0.15" +"@expo/metro-config@npm:58.0.19, @expo/metro-config@npm:~58.0.19": + version: 58.0.19 + resolution: "@expo/metro-config@npm:58.0.19" dependencies: 2g: "npm:^1.0.1" "@babel/code-frame": "npm:^7.20.0" "@babel/core": "npm:^7.20.0" "@babel/generator": "npm:^7.20.5" - "@expo/config": "npm:~58.0.6" - "@expo/env": "npm:~2.5.2" + "@expo/config": "npm:~58.0.9" + "@expo/env": "npm:~2.5.3" "@expo/json-file": "npm:~11.2.1" "@expo/metro": "npm:~58.0.0" "@expo/platforms": "npm:^58.0.1" - "@expo/require-utils": "npm:^58.0.4" + "@expo/require-utils": "npm:^58.0.5" "@expo/spawn-async": "npm:^1.8.0" "@jridgewell/gen-mapping": "npm:^0.3.13" "@jridgewell/remapping": "npm:^2.3.5" "@jridgewell/sourcemap-codec": "npm:^1.5.5" "@jridgewell/trace-mapping": "npm:^0.3.31" - "@react-native/js-polyfills": "npm:0.88.0" + "@react-native/js-polyfills": "npm:0.88.1" browserslist: "npm:^4.25.0" chalk: "npm:^4.1.0" debug: "npm:^4.3.2" @@ -2247,7 +2282,7 @@ __metadata: hermes-parser: "npm:^0.36.0" jsc-safe-url: "npm:^0.2.4" lightningcss: "npm:^1.30.1" - noxcturnal: "npm:^0.1.4" + noxcturnal: "npm:^0.1.11" picomatch: "npm:^4.0.4" postcss: "npm:^8.5.23" resolve-from: "npm:^5.0.0" @@ -2256,7 +2291,7 @@ __metadata: peerDependenciesMeta: expo: optional: true - checksum: 10c0/9255a56dc942464a25c2b4fe91f2d77d7d08bad42eb417f15e32e2dc95e6a87bd47566becb56cb9fafe64ed20806d3f52ebbd157676e8041ae525624ce684042 + checksum: 10c0/075c3629f46cb8a3176b027238792153dd80692329db326761f538653b0fbdc2df5c127d5173e8bf1d944f74bec82342e77c5aa189e58e913c7858b08bfcdfac languageName: node linkType: hard @@ -2296,30 +2331,30 @@ __metadata: languageName: node linkType: hard -"@expo/modules-macros-linux-arm64@npm:0.21.0": - version: 0.21.0 - resolution: "@expo/modules-macros-linux-arm64@npm:0.21.0" +"@expo/modules-macros-linux-arm64@npm:0.27.1": + version: 0.27.1 + resolution: "@expo/modules-macros-linux-arm64@npm:0.27.1" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@expo/modules-macros-linux-x64@npm:0.21.0": - version: 0.21.0 - resolution: "@expo/modules-macros-linux-x64@npm:0.21.0" +"@expo/modules-macros-linux-x64@npm:0.27.1": + version: 0.27.1 + resolution: "@expo/modules-macros-linux-x64@npm:0.27.1" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@expo/modules-macros-win32-arm64@npm:0.21.0": - version: 0.21.0 - resolution: "@expo/modules-macros-win32-arm64@npm:0.21.0" +"@expo/modules-macros-win32-arm64@npm:0.27.1": + version: 0.27.1 + resolution: "@expo/modules-macros-win32-arm64@npm:0.27.1" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@expo/modules-macros-win32-x64@npm:0.21.0": - version: 0.21.0 - resolution: "@expo/modules-macros-win32-x64@npm:0.21.0" +"@expo/modules-macros-win32-x64@npm:0.27.1": + version: 0.27.1 + resolution: "@expo/modules-macros-win32-x64@npm:0.27.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -2365,27 +2400,27 @@ __metadata: languageName: node linkType: hard -"@expo/prebuild-config@npm:^58.0.14": - version: 58.0.14 - resolution: "@expo/prebuild-config@npm:58.0.14" +"@expo/prebuild-config@npm:^58.0.17": + version: 58.0.17 + resolution: "@expo/prebuild-config@npm:58.0.17" dependencies: - "@expo/config": "npm:~58.0.6" - "@expo/config-plugins": "npm:~58.0.9" + "@expo/config": "npm:~58.0.9" + "@expo/config-plugins": "npm:~58.0.11" "@expo/config-types": "npm:^58.0.3" - "@expo/image-utils": "npm:^0.12.5" + "@expo/image-utils": "npm:^0.12.6" "@expo/json-file": "npm:^11.2.1" - "@react-native/normalize-colors": "npm:0.88.0" + "@react-native/normalize-colors": "npm:0.88.1" debug: "npm:^4.3.1" - expo-modules-autolinking: "npm:~58.0.13" + expo-modules-autolinking: "npm:~58.0.16" resolve-from: "npm:^5.0.0" semver: "npm:^7.7.4" - checksum: 10c0/53d43a1bb260b065ce99f37e1a8a7fcebc16d3536db256ebebc41a0c89f2e3f80874f17b11276de940a8d8ff9214d17e9aa0d8aa94ab3d9ba6e83cdfaec585a9 + checksum: 10c0/317d85495b8f4eef4122c4df486da59320c99fd54e49f97cece3076411363269ff9cac95a0891bb7c64621fe01b4f5652674e84d14faa0425d941004dc61d79a languageName: node linkType: hard -"@expo/require-utils@npm:^58.0.4": - version: 58.0.4 - resolution: "@expo/require-utils@npm:58.0.4" +"@expo/require-utils@npm:^58.0.5": + version: 58.0.5 + resolution: "@expo/require-utils@npm:58.0.5" dependencies: "@babel/code-frame": "npm:^7.20.0" "@babel/core": "npm:^7.25.2" @@ -2395,22 +2430,22 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10c0/67678e0c4bc31c99bf2f4a667599bb8294f2a6cdd4e63c388a28e35272b3bdad0d92066b8dc42c09cfe4c0c99a12cac24b4715aae3816d3d0d318abf84d20117 + checksum: 10c0/2217314eef8750a04fb8965dfbe0bfe15f2b0efe4fba120c8c8b0bb1e69914f7ed6156ad3962154d728cc83f5f0e285061c98a44ad9804e849f3b09b18a97698 languageName: node linkType: hard -"@expo/router-server@npm:^58.0.15": - version: 58.0.15 - resolution: "@expo/router-server@npm:58.0.15" +"@expo/router-server@npm:^58.0.19": + version: 58.0.19 + resolution: "@expo/router-server@npm:58.0.19" dependencies: debug: "npm:^4.3.4" peerDependencies: - "@expo/metro-runtime": ^58.0.14 + "@expo/metro-runtime": ^58.0.15 expo: "*" - expo-constants: ^58.0.12 - expo-font: ^58.0.9 + expo-constants: ^58.0.14 + expo-font: ^58.0.11 expo-router: "*" - expo-server: ^58.0.5 + expo-server: ^58.0.6 react: "*" react-dom: "*" react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 || ~19.3.0 @@ -2423,7 +2458,7 @@ __metadata: optional: true react-server-dom-webpack: optional: true - checksum: 10c0/6f5ca6eb7515f4d0e31154e5f06eec060574f3a8e83b5c38b81ff21a5f8251a3b7f3e937ca4bcc36c6f071db5040362572e5eda8c3c9fed54684544af6ea16f7 + checksum: 10c0/22deac47f0408e747e16533fde71c24ab01826b49f968c6994f83042fae55160147da434851f9a00626af783b6c64e2ab98f157d2092bb0592347d563d30920d languageName: node linkType: hard @@ -3125,58 +3160,58 @@ __metadata: languageName: node linkType: hard -"@noxcturnal/noxcturnal-darwin-arm64@npm:0.1.7": - version: 0.1.7 - resolution: "@noxcturnal/noxcturnal-darwin-arm64@npm:0.1.7" +"@noxcturnal/noxcturnal-darwin-arm64@npm:0.1.11": + version: 0.1.11 + resolution: "@noxcturnal/noxcturnal-darwin-arm64@npm:0.1.11" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@noxcturnal/noxcturnal-darwin-x64@npm:0.1.7": - version: 0.1.7 - resolution: "@noxcturnal/noxcturnal-darwin-x64@npm:0.1.7" +"@noxcturnal/noxcturnal-darwin-x64@npm:0.1.11": + version: 0.1.11 + resolution: "@noxcturnal/noxcturnal-darwin-x64@npm:0.1.11" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@noxcturnal/noxcturnal-linux-arm64-gnu@npm:0.1.7": - version: 0.1.7 - resolution: "@noxcturnal/noxcturnal-linux-arm64-gnu@npm:0.1.7" +"@noxcturnal/noxcturnal-linux-arm64-gnu@npm:0.1.11": + version: 0.1.11 + resolution: "@noxcturnal/noxcturnal-linux-arm64-gnu@npm:0.1.11" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@noxcturnal/noxcturnal-linux-arm64-musl@npm:0.1.7": - version: 0.1.7 - resolution: "@noxcturnal/noxcturnal-linux-arm64-musl@npm:0.1.7" +"@noxcturnal/noxcturnal-linux-arm64-musl@npm:0.1.11": + version: 0.1.11 + resolution: "@noxcturnal/noxcturnal-linux-arm64-musl@npm:0.1.11" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@noxcturnal/noxcturnal-linux-x64-gnu@npm:0.1.7": - version: 0.1.7 - resolution: "@noxcturnal/noxcturnal-linux-x64-gnu@npm:0.1.7" +"@noxcturnal/noxcturnal-linux-x64-gnu@npm:0.1.11": + version: 0.1.11 + resolution: "@noxcturnal/noxcturnal-linux-x64-gnu@npm:0.1.11" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@noxcturnal/noxcturnal-linux-x64-musl@npm:0.1.7": - version: 0.1.7 - resolution: "@noxcturnal/noxcturnal-linux-x64-musl@npm:0.1.7" +"@noxcturnal/noxcturnal-linux-x64-musl@npm:0.1.11": + version: 0.1.11 + resolution: "@noxcturnal/noxcturnal-linux-x64-musl@npm:0.1.11" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@noxcturnal/noxcturnal-win32-arm64-msvc@npm:0.1.7": - version: 0.1.7 - resolution: "@noxcturnal/noxcturnal-win32-arm64-msvc@npm:0.1.7" +"@noxcturnal/noxcturnal-win32-arm64-msvc@npm:0.1.11": + version: 0.1.11 + resolution: "@noxcturnal/noxcturnal-win32-arm64-msvc@npm:0.1.11" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@noxcturnal/noxcturnal-win32-x64-msvc@npm:0.1.7": - version: 0.1.7 - resolution: "@noxcturnal/noxcturnal-win32-x64-msvc@npm:0.1.7" +"@noxcturnal/noxcturnal-win32-x64-msvc@npm:0.1.11": + version: 0.1.11 + resolution: "@noxcturnal/noxcturnal-win32-x64-msvc@npm:0.1.11" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -3349,10 +3384,10 @@ __metadata: languageName: node linkType: hard -"@react-native/asset-utils@npm:0.88.0": - version: 0.88.0 - resolution: "@react-native/asset-utils@npm:0.88.0" - checksum: 10c0/3167653f49068022b1797e701c5398bfd3861d609e4e05c0cd6203177326da428471d3adecfc807f531e45688d3ded2210c62c25b133c6cbe8352049705e9f64 +"@react-native/asset-utils@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/asset-utils@npm:0.88.1" + checksum: 10c0/3189049a76b603ef0b13cbd65d6fafbc2e117643f81a2bebb9841254e48b78ab5c0f07b7110cd239bc232796c21aeaa3c9c5237e0c37ce0caa485ffe628d9b3c languageName: node linkType: hard @@ -3366,9 +3401,19 @@ __metadata: languageName: node linkType: hard -"@react-native/babel-preset@npm:0.88.0": - version: 0.88.0 - resolution: "@react-native/babel-preset@npm:0.88.0" +"@react-native/babel-plugin-codegen@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/babel-plugin-codegen@npm:0.88.1" + dependencies: + "@babel/traverse": "npm:^7.29.0" + "@react-native/codegen": "npm:0.88.1" + checksum: 10c0/32a170bc81fc7786c6b32495f6f4290287c14ac4d9edc03969a9aad35cb6281c40c75b3d74b691ee242ee852e2a47bacb3b03511c542f28a6ed3c5b4af02b2ba + languageName: node + linkType: hard + +"@react-native/babel-preset@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/babel-preset@npm:0.88.1" dependencies: "@babel/core": "npm:^7.25.2" "@babel/plugin-proposal-export-default-from": "npm:^7.24.7" @@ -3399,13 +3444,13 @@ __metadata: "@babel/plugin-transform-runtime": "npm:^7.24.7" "@babel/plugin-transform-typescript": "npm:^7.25.2" "@babel/plugin-transform-unicode-regex": "npm:^7.24.7" - "@react-native/babel-plugin-codegen": "npm:0.88.0" + "@react-native/babel-plugin-codegen": "npm:0.88.1" babel-plugin-transform-flow-enums: "npm:^0.0.2" flow-parser: "npm:0.327.0" react-refresh: "npm:^0.14.0" peerDependencies: "@babel/core": "*" - checksum: 10c0/8fea293d4a7829e680b5ca8fe565dbeb630f876d8f69e4c0f79ec54015edee33b398b1ff21bc01738067ad300ce4c01cd09a1f550059ad1f1d618268c79b779a + checksum: 10c0/c5eafaa1df33c8231cb381b6464f8aaf949899a5419c5c93eb9f14d058221368db242f9efb21381d8cc2fdd389cf09f6ea6c35164a408ddc8773c94a3e93b750 languageName: node linkType: hard @@ -3426,12 +3471,29 @@ __metadata: languageName: node linkType: hard -"@react-native/community-cli-plugin@npm:0.88.0": - version: 0.88.0 - resolution: "@react-native/community-cli-plugin@npm:0.88.0" +"@react-native/codegen@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/codegen@npm:0.88.1" + dependencies: + "@babel/core": "npm:^7.25.2" + "@babel/parser": "npm:^7.29.0" + flow-parser: "npm:0.327.0" + invariant: "npm:^2.2.4" + nullthrows: "npm:^1.1.1" + tinyglobby: "npm:^0.2.15" + yargs: "npm:^17.6.2" + peerDependencies: + "@babel/core": "*" + checksum: 10c0/33f3ef562dcfede10b39e3d68d5be0539314a2737421d385d334a18c96507ba00c430f1c99fad69a9e3c94c3e01c2f07b55fa0b95f8cf18623d32a7e984133cd + languageName: node + linkType: hard + +"@react-native/community-cli-plugin@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/community-cli-plugin@npm:0.88.1" dependencies: - "@react-native/asset-utils": "npm:0.88.0" - "@react-native/dev-middleware": "npm:0.88.0" + "@react-native/asset-utils": "npm:0.88.1" + "@react-native/dev-middleware": "npm:0.88.1" commander: "npm:^12.0.0" debug: "npm:^4.4.0" invariant: "npm:^2.2.4" @@ -3439,41 +3501,41 @@ __metadata: semver: "npm:^7.1.3" peerDependencies: "@react-native-community/cli": "*" - "@react-native/metro-config": 0.88.0 + "@react-native/metro-config": 0.88.1 peerDependenciesMeta: "@react-native-community/cli": optional: true "@react-native/metro-config": optional: true - checksum: 10c0/99a3d10e51593857d7c7b50ee3a3e3d1dd00188557195a91d9b00291b507436470483262f62d40b22bc866bbbad9a1561fd7fbb92360fcf25201d40468d40607 + checksum: 10c0/aeee1cd978bdf4f60994888465ca6af8b94d6cb706a06b1fa2dad1b5e11f62e70c8042afe1beb246c7fb5bff684aa951606ad45809faf7a18a0e3e6a81a005b8 languageName: node linkType: hard -"@react-native/debugger-frontend@npm:0.88.0": - version: 0.88.0 - resolution: "@react-native/debugger-frontend@npm:0.88.0" - checksum: 10c0/8ff330ede74593b214550497754fa84e9ff8274d645aedc466bdb456bdad980170e996ad8c0cd3cd7921b15cb4760a2003e51f60a7f5868ba10936292f417e6c +"@react-native/debugger-frontend@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/debugger-frontend@npm:0.88.1" + checksum: 10c0/b39d57e9f6aee083bf8b3d19fc9c22986767ba76d3d813eaf093a5e8b50a0a385d72aadd50de1c03754a5fdfbcaf4d12349f77ccd01fd18713321d49a3ddc04c languageName: node linkType: hard -"@react-native/debugger-shell@npm:0.88.0": - version: 0.88.0 - resolution: "@react-native/debugger-shell@npm:0.88.0" +"@react-native/debugger-shell@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/debugger-shell@npm:0.88.1" dependencies: cross-spawn: "npm:^7.0.6" debug: "npm:^4.4.0" fb-dotslash: "npm:0.5.8" - checksum: 10c0/7df74abca6a2c68ea0fbf57d8d896866476958a1505aa60ae466623646f78ef4eedbd548bfd3776090258a29c67229b69492c975de6b19583075d6d2e4253823 + checksum: 10c0/98aeca1f2f2edf86c47f112b364c77dab00314cc77262ba1793ad85de8dd76ee5413a66320e033230e203a25ae864fa1fa18475e7577a4bbe25f7d4b7e1eef5d languageName: node linkType: hard -"@react-native/dev-middleware@npm:0.88.0": - version: 0.88.0 - resolution: "@react-native/dev-middleware@npm:0.88.0" +"@react-native/dev-middleware@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/dev-middleware@npm:0.88.1" dependencies: "@isaacs/ttlcache": "npm:^1.4.1" - "@react-native/debugger-frontend": "npm:0.88.0" - "@react-native/debugger-shell": "npm:0.88.0" + "@react-native/debugger-frontend": "npm:0.88.1" + "@react-native/debugger-shell": "npm:0.88.1" connect: "npm:^3.6.5" debug: "npm:^4.4.0" invariant: "npm:^2.2.4" @@ -3481,17 +3543,17 @@ __metadata: open: "npm:^8.4.2" serve-static: "npm:^1.16.2" ws: "npm:^7.5.10" - checksum: 10c0/17b1e2d72929cf0ea8904f73f6537e4ef30c0fe62a289bb14d6cbb4642e02ad091f33f1a7e4f955d0432bdc737792761064e8696dc9e13613ae26f9b3c707c71 + checksum: 10c0/740aecce1af5da2544495d317206dbf2826c5c5ae440df61f8105c972334e885defdff6defefe0274a3a8294bfe8666c6b14f4c2915b1856c6357ce11c880d86 languageName: node linkType: hard -"@react-native/eslint-config@npm:0.88.0": - version: 0.88.0 - resolution: "@react-native/eslint-config@npm:0.88.0" +"@react-native/eslint-config@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/eslint-config@npm:0.88.1" dependencies: "@babel/core": "npm:^7.25.2" "@babel/eslint-parser": "npm:^7.25.1" - "@react-native/eslint-plugin": "npm:0.88.0" + "@react-native/eslint-plugin": "npm:0.88.1" "@typescript-eslint/eslint-plugin": "npm:^8.59.2" "@typescript-eslint/parser": "npm:^8.59.2" eslint-config-prettier: "npm:^8.5.0" @@ -3504,93 +3566,93 @@ __metadata: peerDependencies: eslint: ^8.0.0 || ^9.0.0 prettier: ">=2" - checksum: 10c0/42064962c792b879a85bad8ffeb73cf93841e58cc28e88255b786d119f83667a686ac798a316899cc3a814bf636a482b658f6be1412c377d353302fb18861887 + checksum: 10c0/70728d9b072f50ba0bba7f0da50fb0ba717f4a639927bd9f0d8238c7f4d402d6bc4cb0de3f8a9a1468c4222011c202aa5a0247e26d2d2fff3018f7dcadc7237c languageName: node linkType: hard -"@react-native/eslint-plugin@npm:0.88.0": - version: 0.88.0 - resolution: "@react-native/eslint-plugin@npm:0.88.0" - checksum: 10c0/71a0cd872fab0e9ebeb6c40da30d9758f1098307f38d0c99dbe6c13c19b15b9af36e83bc9f0a3fda7b9ee56b72843525ae16aae1d7e0cd60d38438c6265a6823 +"@react-native/eslint-plugin@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/eslint-plugin@npm:0.88.1" + checksum: 10c0/2b83c9cc9559fc0b1bdd72e9a4facbc6c0c16b2eed22434b6639937da2a9ab525f33a4dcb2f0b060fe4217d21301fca186ed329658417e05d7296a2b0be1d703 languageName: node linkType: hard -"@react-native/gradle-plugin@npm:0.88.0": - version: 0.88.0 - resolution: "@react-native/gradle-plugin@npm:0.88.0" - checksum: 10c0/235f15d880728fe3e3dc3250827f0b6d12009eea5f7f6f114620a558d8a4d54041c9df103d1e18c80209e80f26dfad1a090409410d60df08f3ef287a8598fe2c +"@react-native/gradle-plugin@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/gradle-plugin@npm:0.88.1" + checksum: 10c0/b78162926d85c4261338c1f00f0f423d949b619f61eaada6be20d07297e675c7923edeb1234df23be8b0cb3d334fd08e24af4a10f122df64e9d6ad2e5c4ed8fa languageName: node linkType: hard -"@react-native/jest-preset@npm:0.88.0": - version: 0.88.0 - resolution: "@react-native/jest-preset@npm:0.88.0" +"@react-native/jest-preset@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/jest-preset@npm:0.88.1" dependencies: "@jest/create-cache-key-function": "npm:^29.7.0" - "@react-native/js-polyfills": "npm:0.88.0" + "@react-native/js-polyfills": "npm:0.88.1" babel-jest: "npm:^29.7.0" jest-environment-node: "npm:^29.7.0" regenerator-runtime: "npm:^0.13.2" peerDependencies: react: ^19.2.3 - checksum: 10c0/0a825fd5313df613b3ed45211a0b0c38b555b4fc35e7d6c047d223b8a05a3fbf2afb64859fb4a654fadf6b4118bdbb7a1d4a99ddd1f01e3009b2385315209bac + checksum: 10c0/ccc82f5faaae56b3e9fce5d8112dbaef30d87a9bc905d199813c3620ee4aad5ef87eeafa20aae099d1d9740b2989c00f5dd6e182af71ac8864178b150ab1b873 languageName: node linkType: hard -"@react-native/js-polyfills@npm:0.88.0": - version: 0.88.0 - resolution: "@react-native/js-polyfills@npm:0.88.0" - checksum: 10c0/33ef63a684f60e2bf6811d7746dbd723885c75b5a7a17e99769e84f963bd3c4e539b82a636cd2492d5f95aa250ea980a3379b19b185f5dfcdaed0c3c72e7b210 +"@react-native/js-polyfills@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/js-polyfills@npm:0.88.1" + checksum: 10c0/b31ab4db95dbbc3d89fbd7d489cdede3a153f8091d918b8394b753424c12902c4e8d498385a28e749fc6bd449550757f4e28a6ed766fed1fdbf0d2aab0a44502 languageName: node linkType: hard -"@react-native/metro-babel-transformer@npm:0.88.0": - version: 0.88.0 - resolution: "@react-native/metro-babel-transformer@npm:0.88.0" +"@react-native/metro-babel-transformer@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/metro-babel-transformer@npm:0.88.1" dependencies: "@babel/core": "npm:^7.25.2" - "@react-native/babel-preset": "npm:0.88.0" + "@react-native/babel-preset": "npm:0.88.1" flow-parser: "npm:0.327.0" nullthrows: "npm:^1.1.1" peerDependencies: "@babel/core": "*" - checksum: 10c0/15be95e426e7a1d8586b17fc5f68029a4e57158cda5de9e444cdb2d287fc652e343bb01281a8af6ec394a35bdda3e0b420dd49ef59d3559255ec84a7beab2b64 + checksum: 10c0/08fbf56efada3cc0dd5263f774f73f3aef5d9fd403df9cc62d3a801e18774ee6906a11a21d2ec7e7d0e0428c306302692f94a97c2f786db6ea82dd59115c9ef9 languageName: node linkType: hard -"@react-native/metro-config@npm:^0.88.0": - version: 0.88.0 - resolution: "@react-native/metro-config@npm:0.88.0" +"@react-native/metro-config@npm:^0.88.1": + version: 0.88.1 + resolution: "@react-native/metro-config@npm:0.88.1" dependencies: - "@react-native/js-polyfills": "npm:0.88.0" - "@react-native/metro-babel-transformer": "npm:0.88.0" + "@react-native/js-polyfills": "npm:0.88.1" + "@react-native/metro-babel-transformer": "npm:0.88.1" metro-config: "npm:^0.87.1" metro-runtime: "npm:^0.87.1" - checksum: 10c0/77656b06dc867b210cf3cf1813f813df69efab03fc4674a2002fa7e045080e96fdc6c8485f8f2e2e29019109f0f93fb7b62a1a0d855fe1e528d24cba2cc16e84 + checksum: 10c0/070e2f8ef26688a87bc67206cf4a989745e2afe63c475b6890dffed75ee76fa28f7d301b7fa1e7f56cfee1a2480cbb2a31450cb27666f98c338ef59f1d5a66c8 languageName: node linkType: hard -"@react-native/normalize-colors@npm:0.88.0": - version: 0.88.0 - resolution: "@react-native/normalize-colors@npm:0.88.0" - checksum: 10c0/2e65f61d1063f838ae3c9b107911f74a501ad8d63744be1dec73f4cf404d4660de9ef0e45b0ed6e095897dd944ea3034b5d8b6e1a6d6547fc7225f33db885937 +"@react-native/normalize-colors@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/normalize-colors@npm:0.88.1" + checksum: 10c0/3db7e7b7ac754f648759cced1cccfb0e2526ed5088495312da7d2b797e1bb1b9df351fd583e4c6bf09fd5c99a757335bc503736673962df173fe9afeab6c1205 languageName: node linkType: hard -"@react-native/virtualized-lists@npm:0.88.0": - version: 0.88.0 - resolution: "@react-native/virtualized-lists@npm:0.88.0" +"@react-native/virtualized-lists@npm:0.88.1": + version: 0.88.1 + resolution: "@react-native/virtualized-lists@npm:0.88.1" dependencies: invariant: "npm:^2.2.4" nullthrows: "npm:^1.1.1" peerDependencies: "@types/react": ^19.2.0 react: "*" - react-native: 0.88.0 + react-native: 0.88.1 peerDependenciesMeta: "@types/react": optional: true - checksum: 10c0/e002afd6550e03d6ea4e8044f9200dc1790573ec01dad80848a341c2bde87c0edc2ba5f089e4e40a5e42820139d0d440040ce386a00c0f8ba3416481a6b2ce88 + checksum: 10c0/663f3798bb619677549e047703d102e28244b569fedbcf493fbf4f056f5a06093cbf12470873593f7c436357c11660d41eed55638fbac404e2438b4b2e06fa13 languageName: node linkType: hard @@ -3831,6 +3893,13 @@ __metadata: languageName: node linkType: hard +"@types/emscripten@npm:^1.41.6": + version: 1.41.6 + resolution: "@types/emscripten@npm:1.41.6" + checksum: 10c0/b51a730b6cb2848ac5e3f0536707bf656b6887be21f20e96b01e74a59ed52319dd0942f49b1072b1ca6d38df19e2d633f540df54d6ba1f7216be14af7373a41f + languageName: node + linkType: hard + "@types/estree@npm:^1.0.6": version: 1.0.8 resolution: "@types/estree@npm:1.0.8" @@ -4729,7 +4798,7 @@ __metadata: languageName: node linkType: hard -"babel-preset-expo@npm:~58.0.0, babel-preset-expo@npm:~58.0.18": +"babel-preset-expo@npm:~58.0.0": version: 58.0.18 resolution: "babel-preset-expo@npm:58.0.18" dependencies: @@ -4791,6 +4860,68 @@ __metadata: languageName: node linkType: hard +"babel-preset-expo@npm:~58.0.22": + version: 58.0.22 + resolution: "babel-preset-expo@npm:58.0.22" + dependencies: + "@babel/generator": "npm:^7.20.5" + "@babel/helper-module-imports": "npm:^7.25.9" + "@babel/plugin-proposal-decorators": "npm:^7.12.9" + "@babel/plugin-proposal-export-default-from": "npm:^7.24.7" + "@babel/plugin-syntax-dynamic-import": "npm:^7.8.3" + "@babel/plugin-syntax-export-default-from": "npm:^7.24.7" + "@babel/plugin-syntax-nullish-coalescing-operator": "npm:^7.8.3" + "@babel/plugin-syntax-optional-chaining": "npm:^7.8.3" + "@babel/plugin-transform-async-generator-functions": "npm:^7.25.4" + "@babel/plugin-transform-async-to-generator": "npm:^7.24.7" + "@babel/plugin-transform-block-scoping": "npm:^7.25.0" + "@babel/plugin-transform-class-properties": "npm:^7.25.4" + "@babel/plugin-transform-class-static-block": "npm:^7.27.1" + "@babel/plugin-transform-classes": "npm:^7.25.4" + "@babel/plugin-transform-destructuring": "npm:^7.24.8" + "@babel/plugin-transform-export-namespace-from": "npm:^7.25.9" + "@babel/plugin-transform-flow-strip-types": "npm:^7.25.2" + "@babel/plugin-transform-for-of": "npm:^7.24.7" + "@babel/plugin-transform-logical-assignment-operators": "npm:^7.24.7" + "@babel/plugin-transform-modules-commonjs": "npm:^7.24.8" + "@babel/plugin-transform-named-capturing-groups-regex": "npm:^7.24.7" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.24.7" + "@babel/plugin-transform-object-rest-spread": "npm:^7.24.7" + "@babel/plugin-transform-optional-catch-binding": "npm:^7.24.7" + "@babel/plugin-transform-optional-chaining": "npm:^7.24.8" + "@babel/plugin-transform-parameters": "npm:^7.24.7" + "@babel/plugin-transform-private-methods": "npm:^7.24.7" + "@babel/plugin-transform-private-property-in-object": "npm:^7.24.7" + "@babel/plugin-transform-react-display-name": "npm:^7.24.7" + "@babel/plugin-transform-react-jsx": "npm:^7.28.6" + "@babel/plugin-transform-react-jsx-development": "npm:^7.27.1" + "@babel/plugin-transform-react-pure-annotations": "npm:^7.27.1" + "@babel/plugin-transform-runtime": "npm:^7.24.7" + "@babel/plugin-transform-typescript": "npm:^7.25.2" + "@babel/plugin-transform-unicode-regex": "npm:^7.24.7" + "@babel/preset-typescript": "npm:^7.23.0" + "@react-native/babel-plugin-codegen": "npm:0.88.1" + babel-plugin-react-compiler: "npm:^1.0.0" + babel-plugin-react-native-web: "npm:~0.21.4" + babel-plugin-syntax-hermes-parser: "npm:^0.36.0" + babel-plugin-transform-flow-enums: "npm:^0.0.2" + debug: "npm:^4.3.4" + peerDependencies: + "@babel/runtime": ^7.20.0 + expo: "*" + expo-widgets: ^58.0.25 + react-refresh: ">=0.14.0 <1.0.0" + peerDependenciesMeta: + "@babel/runtime": + optional: true + expo: + optional: true + expo-widgets: + optional: true + checksum: 10c0/218dcb1fd8fa021f8b4060855efe675f6e98c8d2786f75d1a8e45a2c26803dce5d6e8890032058e50d0fcf4c9e0efd88368405554027e1ba95fdc96835b08c22 + languageName: node + linkType: hard + "babel-preset-jest@npm:^29.6.3": version: 29.6.3 resolution: "babel-preset-jest@npm:29.6.3" @@ -4817,6 +4948,15 @@ __metadata: languageName: node linkType: hard +"barcode-detector@npm:^3.0.0": + version: 3.2.3 + resolution: "barcode-detector@npm:3.2.3" + dependencies: + zxing-wasm: "npm:3.1.6" + checksum: 10c0/6e1b711726c3a4916b6f11508cc13bfcb580591f39e57906c6dcca11c14a607f74c8469e26acfe53f8fa61766ababbf9f87fae8e123b0822fd104b206621df33 + languageName: node + linkType: hard + "base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -6520,17 +6660,28 @@ __metadata: languageName: node linkType: hard -"expo-asset@npm:~58.0.17": - version: 58.0.17 - resolution: "expo-asset@npm:58.0.17" +"expo-asset@npm:~58.0.19": + version: 58.0.19 + resolution: "expo-asset@npm:58.0.19" dependencies: - "@expo/image-utils": "npm:^0.12.5" - expo-constants: "npm:~58.0.12" + "@expo/image-utils": "npm:^0.12.6" + expo-constants: "npm:~58.0.14" + peerDependencies: + expo: "*" + react: "*" + react-native: "*" + checksum: 10c0/8a2c6c9ba3e40a4351adcc2666506fd815264213d250062c3d918653f2d3ba664fd95e4db3d38c0a51c0d8427f707537ce31edc952df4030e6dc3f090890405e + languageName: node + linkType: hard + +"expo-blur@npm:~58.0.5": + version: 58.0.5 + resolution: "expo-blur@npm:58.0.5" peerDependencies: expo: "*" react: "*" react-native: "*" - checksum: 10c0/0c980b2fcfe9d402a96f39781485e969b534e775323adafed5d9796ccf5ae0c5122f0e6b0aa3c1c1103a05535863dad557e782d901b5c410811895f8298d81ef + checksum: 10c0/a35707fa2c5f0416828d8becca9182ddad00b10688dab857f7fe2862b886713ec1ed4dc268c0f332205eea89ff202040eedd265d899a0ea45712fa8119b151d7 languageName: node linkType: hard @@ -6547,37 +6698,65 @@ __metadata: languageName: node linkType: hard -"expo-constants@npm:~58.0.12": - version: 58.0.12 - resolution: "expo-constants@npm:58.0.12" +"expo-camera@npm:~58.0.0": + version: 58.0.14 + resolution: "expo-camera@npm:58.0.14" + dependencies: + barcode-detector: "npm:^3.0.0" + peerDependencies: + expo: "*" + react: "*" + react-native: "*" + react-native-web: "*" + peerDependenciesMeta: + react-native-web: + optional: true + checksum: 10c0/a0bd03124f46d0a7b01beb37525cb775864e64468d570f4a663de0ba5e09c1e1d8800056d77afff21b02c9140bb5cfae7c64691bd66a583f8ed6cf69dffe7b52 + languageName: node + linkType: hard + +"expo-constants@npm:~58.0.14": + version: 58.0.14 + resolution: "expo-constants@npm:58.0.14" dependencies: - "@expo/env": "npm:~2.5.2" - "@expo/require-utils": "npm:^58.0.4" + "@expo/env": "npm:~2.5.3" + "@expo/require-utils": "npm:^58.0.5" peerDependencies: expo: "*" react-native: "*" - checksum: 10c0/e2ddd039556854f8cfed4e71ebcea5647597ee3b385242801c876cb1c2c667235438cd2de11b035597a2206503526eb49a0c1c9b445d5e979f7df939a0046ea1 + checksum: 10c0/3b3819cabfb274c7e69dadb44339bbc238b8dd8511565e6dccc89685b3407bd548fdd56bf8c8d7fdd02943081293aa25d180e5108ceb5305980e72e92810fb84 languageName: node linkType: hard -"expo-file-system@npm:~58.0.10": - version: 58.0.10 - resolution: "expo-file-system@npm:58.0.10" +"expo-file-system@npm:~58.0.13": + version: 58.0.13 + resolution: "expo-file-system@npm:58.0.13" peerDependencies: expo: "*" react-native: "*" - checksum: 10c0/20622a407a2353a098c35e35738a0f5b45e902245b2f4154f2c88eae021340a092fe50f71615547e5e8bbf28c45ed186598dc7427afb70aeee4bc59670e755c1 + checksum: 10c0/e68fb7b8ee0b99c0683037f9754e90d176bcfae4ce823f3e2c1fcede9cebf4428a420e529c870b10af3e6b3a2160933b652b700ca68d57f08960512764868c66 languageName: node linkType: hard -"expo-font@npm:~58.0.9": - version: 58.0.9 - resolution: "expo-font@npm:58.0.9" +"expo-font@npm:~58.0.11": + version: 58.0.11 + resolution: "expo-font@npm:58.0.11" peerDependencies: expo: "*" react: "*" react-native: "*" - checksum: 10c0/769c642d90e4adfe402ad823f7d4036cf95edc5fa29c9012251c9bc10d0ecc8a88c016b29b838863c9e9b07b74365a5022f990231ab073d51af33d3e87fee57e + checksum: 10c0/238d03dfb63e9d2dbe2d03c8bc87ea87909fa22057b788c36e7421f41a23a2754c60773e30c2e28c6ba29b01406d53a57b9bc86bce60bf575613e4eff835b1c9 + languageName: node + linkType: hard + +"expo-glass-effect@npm:~58.0.5": + version: 58.0.5 + resolution: "expo-glass-effect@npm:58.0.5" + peerDependencies: + expo: "*" + react: "*" + react-native: "*" + checksum: 10c0/a4fcf07afcb5d97561b60bf460fb59caa980eea1a1e69b0b19d0c094ba59cb1907d883e504ca73fd61e1f0f5d3549b01ee33d79fd2a1d0012b1e248b7690bc33 languageName: node linkType: hard @@ -6591,38 +6770,39 @@ __metadata: languageName: node linkType: hard -"expo-modules-autolinking@npm:~58.0.13": - version: 58.0.13 - resolution: "expo-modules-autolinking@npm:58.0.13" +"expo-modules-autolinking@npm:~58.0.16": + version: 58.0.16 + resolution: "expo-modules-autolinking@npm:58.0.16" dependencies: "@expo/platforms": "npm:^58.0.1" - "@expo/require-utils": "npm:^58.0.4" + "@expo/require-utils": "npm:^58.0.5" "@expo/spawn-async": "npm:^1.8.0" chalk: "npm:^4.1.0" commander: "npm:^7.2.0" bin: expo-modules-autolinking: bin/expo-modules-autolinking.js - checksum: 10c0/8d923184475960f487f90fd5f671e44c92850c49c3185900d61c925deaad47e455f17f97f20943ba44294d6e41989a9d95a8ca9dae6f26d1c8a38ed0c2977a81 + checksum: 10c0/6fcf7add94ea83f56c2a5809662031ea0db65bccf7202098a58e10af5b5a33914f8bc07b41e0d48a21af0b933b3ecc25c5961c339b31719c81543083f8ae214d languageName: node linkType: hard -"expo-modules-cli@npm:~0.3.1": - version: 0.3.1 - resolution: "expo-modules-cli@npm:0.3.1" +"expo-modules-cli@npm:~0.4.0": + version: 0.4.0 + resolution: "expo-modules-cli@npm:0.4.0" dependencies: + "@babel/parser": "npm:^7.20.0" arg: "npm:^5.0.2" bin: expo-modules: bin/expo-modules.js - checksum: 10c0/003da858f0992d6271dfebe23319de82922c1f38fc913407c19ecb4b84bf316374aa73a27b1a76bc928881e9529300f33f989eaeedfafb252546709b7e7b0224 + checksum: 10c0/92473bfb34ea616c67c2904d41e42c431b959c0a290f70c679ab12ffde62bfe1c3e1ee144cf983625ad5054245106e57ca5db85d8dd749c8e4f99cb7a10996af languageName: node linkType: hard -"expo-modules-core@npm:~58.0.23": - version: 58.0.23 - resolution: "expo-modules-core@npm:58.0.23" +"expo-modules-core@npm:~58.0.28": + version: 58.0.28 + resolution: "expo-modules-core@npm:58.0.28" dependencies: - expo-modules-jsi: "npm:~58.0.16" - expo-modules-macros: "npm:0.21.0" + expo-modules-jsi: "npm:~58.0.21" + expo-modules-macros: "npm:0.27.1" invariant: "npm:^2.2.4" peerDependencies: react: "*" @@ -6631,27 +6811,27 @@ __metadata: peerDependenciesMeta: react-native-worklets: optional: true - checksum: 10c0/247c0d79fa3fe402f56d968932f2b9f0d66d8da7ba5246a3bed13f07226e243c2cfe4ac4880160c6ac2bb2e0cb41dada3d3073a4f126fed05b4a98f5c6c5a8d3 + checksum: 10c0/9ee83322654d56c585f7b97e67cf0561b60967bdd0b4544e8e4859a9e239780c5514d2857a204b4e3fb8d3e37fbd309288311a62c0946fde6cbb5241c1238a2c languageName: node linkType: hard -"expo-modules-jsi@npm:~58.0.16": - version: 58.0.16 - resolution: "expo-modules-jsi@npm:58.0.16" +"expo-modules-jsi@npm:~58.0.21": + version: 58.0.21 + resolution: "expo-modules-jsi@npm:58.0.21" peerDependencies: react-native: "*" - checksum: 10c0/71298d5edbb32c7095f011700b52e71d4e7ffb9c6cadd53d26a2951f6fa458bac7253959ac49a384b176ab4834351e1ed0fb0bc17428e602451ede1595a5fe04 + checksum: 10c0/c550e2695abef7c20f2e4d2c1dd049aed69cb4bd6e10a0a45f3d9587efbb50f8cd99e7c3e3486d1db04de8f1b36d1b12bba79caa22c8c21fce0ec82f973dd494 languageName: node linkType: hard -"expo-modules-macros@npm:0.21.0": - version: 0.21.0 - resolution: "expo-modules-macros@npm:0.21.0" +"expo-modules-macros@npm:0.27.1": + version: 0.27.1 + resolution: "expo-modules-macros@npm:0.27.1" dependencies: - "@expo/modules-macros-linux-arm64": "npm:0.21.0" - "@expo/modules-macros-linux-x64": "npm:0.21.0" - "@expo/modules-macros-win32-arm64": "npm:0.21.0" - "@expo/modules-macros-win32-x64": "npm:0.21.0" + "@expo/modules-macros-linux-arm64": "npm:0.27.1" + "@expo/modules-macros-linux-x64": "npm:0.27.1" + "@expo/modules-macros-win32-arm64": "npm:0.27.1" + "@expo/modules-macros-win32-x64": "npm:0.27.1" dependenciesMeta: "@expo/modules-macros-linux-arm64": optional: true @@ -6661,7 +6841,7 @@ __metadata: optional: true "@expo/modules-macros-win32-x64": optional: true - checksum: 10c0/2b4a12a2b3a2af7819367ee662a8a375cec9c91e25cd93230bc0f2de73de1a0d3b8918e7caa0c7a78a0150a3a1d1a47878603963055f472cb3aacd6a546b2a78 + checksum: 10c0/bfe93daf40f1370fe702f0590e46af6152593e59f4dc4a7d1ab59f9b3464e7c7cc8a1848b08c0ad40f6dc2663cb426874dfa0814a8c8288c1ba86a04d2464cfa languageName: node linkType: hard @@ -6677,39 +6857,67 @@ __metadata: languageName: node linkType: hard -"expo-server@npm:^58.0.5": - version: 58.0.5 - resolution: "expo-server@npm:58.0.5" - checksum: 10c0/5aac2db7143495f0c715ddb83bd039f04e35ad756e82d51204373fa331b650586a5071cd42087651efd3724f7ac3de8afd0f9c404381be2d6bcd83cc9ced3e6e +"expo-server@npm:^58.0.6": + version: 58.0.6 + resolution: "expo-server@npm:58.0.6" + checksum: 10c0/f604dc15ce82eab9af157f14d2154065071736df4402fbdc57ec47f3aca3cfb0fb292e90ab2215081051374dde9236e6021ddcedb1eaaf6dbabb16da7ff0a9a0 languageName: node linkType: hard -"expo@npm:~58.0.16": - version: 58.0.16 - resolution: "expo@npm:58.0.16" +"expo-splash-screen@npm:~58.0.12": + version: 58.0.12 + resolution: "expo-splash-screen@npm:58.0.12" + dependencies: + "@expo/config-plugins": "npm:~58.0.11" + "@expo/image-utils": "npm:^0.12.6" + xml2js: "npm:0.6.0" + peerDependencies: + expo: "*" + checksum: 10c0/ec984becd3abf31cb5f1bc794623907aa7620922b01e20e7e1ccbd693f9e61c51f27a6e834bbdef112383ba8e61260c127e7f8ca92ceaea50af121687e623804 + languageName: node + linkType: hard + +"expo-symbols@npm:~58.0.4": + version: 58.0.4 + resolution: "expo-symbols@npm:58.0.4" + dependencies: + "@expo-google-fonts/material-symbols": "npm:^0.4.1" + sf-symbols-typescript: "npm:^2.0.0" + peerDependencies: + expo: "*" + expo-font: "*" + react: "*" + react-native: "*" + checksum: 10c0/fd74782eca429c4be835b24133254b1178bb899ff617506705ed0c7fe95e1d6967b542f0ebc40e2f3e030316c91c265785f586476e546e7fdcf8f674795f4030 + languageName: node + linkType: hard + +"expo@npm:~58.0.21": + version: 58.0.21 + resolution: "expo@npm:58.0.21" dependencies: "@babel/runtime": "npm:^7.20.0" - "@expo/cli": "npm:^58.1.13" - "@expo/config": "npm:~58.0.6" - "@expo/config-plugins": "npm:~58.0.9" + "@expo/cli": "npm:^58.1.17" + "@expo/config": "npm:~58.0.9" + "@expo/config-plugins": "npm:~58.0.11" "@expo/devtools": "npm:~58.0.2" "@expo/dom-webview": "npm:~58.0.2" - "@expo/fingerprint": "npm:^0.21.5" - "@expo/local-build-cache-provider": "npm:^58.0.6" - "@expo/log-box": "npm:^58.0.12" + "@expo/fingerprint": "npm:^0.21.7" + "@expo/local-build-cache-provider": "npm:^58.0.9" + "@expo/log-box": "npm:^58.0.13" "@expo/log-box-utils": "npm:^58.0.2" "@expo/metro": "npm:~58.0.0" - "@expo/metro-config": "npm:~58.0.15" + "@expo/metro-config": "npm:~58.0.19" "@ungap/structured-clone": "npm:^1.3.0" - babel-preset-expo: "npm:~58.0.18" - expo-asset: "npm:~58.0.17" - expo-constants: "npm:~58.0.12" - expo-file-system: "npm:~58.0.10" - expo-font: "npm:~58.0.9" + babel-preset-expo: "npm:~58.0.22" + expo-asset: "npm:~58.0.19" + expo-constants: "npm:~58.0.14" + expo-file-system: "npm:~58.0.13" + expo-font: "npm:~58.0.11" expo-keep-awake: "npm:~58.0.3" - expo-modules-autolinking: "npm:~58.0.13" - expo-modules-cli: "npm:~0.3.1" - expo-modules-core: "npm:~58.0.23" + expo-modules-autolinking: "npm:~58.0.16" + expo-modules-cli: "npm:~0.4.0" + expo-modules-core: "npm:~58.0.28" pretty-format: "npm:^29.7.0" react-refresh: "npm:^0.14.2" whatwg-url-minimum: "npm:^0.2.0" @@ -6737,7 +6945,7 @@ __metadata: expo-modules: bin/modules expo-modules-autolinking: bin/autolinking fingerprint: bin/fingerprint - checksum: 10c0/08a15db04fa4d1a6e5100a670359d3f43d8ebdb831a3a46827b9c6932686c392de15163881b2d37e6dfe62dc5cbfd8fbf3c32076f98b05802d07072cc9f83175 + checksum: 10c0/b431933e568a7e8f7127ab00cd64779db350eb365ac350c02e8e2cccc1c8615d06950850a8ffa01555ec9eeab9fc95394cc6461a6442c09adaa3a912ccc6c0c5 languageName: node linkType: hard @@ -7424,10 +7632,10 @@ __metadata: languageName: node linkType: hard -"hermes-compiler@npm:260318099.0.4": - version: 260318099.0.4 - resolution: "hermes-compiler@npm:260318099.0.4" - checksum: 10c0/27814371102c540d10e4d3768ec9e53bd18fd8c448f63d648513ded595594bdc9a332123b56e8a8e534b05b23a34eafb4db61e49e29b7b8c4d6016ce7c537145 +"hermes-compiler@npm:260318099.0.5": + version: 260318099.0.5 + resolution: "hermes-compiler@npm:260318099.0.5" + checksum: 10c0/0194e815661498cd384aa4de24dfb3754e856fe473782fa6861d9191c84fde926ca3f3a6921231418e810655ee9e231e6e701d3f3dca10aa1079162dd0623ab9 languageName: node linkType: hard @@ -9887,19 +10095,19 @@ __metadata: languageName: node linkType: hard -"noxcturnal@npm:^0.1.4": - version: 0.1.7 - resolution: "noxcturnal@npm:0.1.7" +"noxcturnal@npm:^0.1.11": + version: 0.1.11 + resolution: "noxcturnal@npm:0.1.11" dependencies: "@babel/code-frame": "npm:^7.20.0" - "@noxcturnal/noxcturnal-darwin-arm64": "npm:0.1.7" - "@noxcturnal/noxcturnal-darwin-x64": "npm:0.1.7" - "@noxcturnal/noxcturnal-linux-arm64-gnu": "npm:0.1.7" - "@noxcturnal/noxcturnal-linux-arm64-musl": "npm:0.1.7" - "@noxcturnal/noxcturnal-linux-x64-gnu": "npm:0.1.7" - "@noxcturnal/noxcturnal-linux-x64-musl": "npm:0.1.7" - "@noxcturnal/noxcturnal-win32-arm64-msvc": "npm:0.1.7" - "@noxcturnal/noxcturnal-win32-x64-msvc": "npm:0.1.7" + "@noxcturnal/noxcturnal-darwin-arm64": "npm:0.1.11" + "@noxcturnal/noxcturnal-darwin-x64": "npm:0.1.11" + "@noxcturnal/noxcturnal-linux-arm64-gnu": "npm:0.1.11" + "@noxcturnal/noxcturnal-linux-arm64-musl": "npm:0.1.11" + "@noxcturnal/noxcturnal-linux-x64-gnu": "npm:0.1.11" + "@noxcturnal/noxcturnal-linux-x64-musl": "npm:0.1.11" + "@noxcturnal/noxcturnal-win32-arm64-msvc": "npm:0.1.11" + "@noxcturnal/noxcturnal-win32-x64-msvc": "npm:0.1.11" "@oxc-project/types": "npm:0.154.0" dependenciesMeta: "@noxcturnal/noxcturnal-darwin-arm64": @@ -9918,7 +10126,7 @@ __metadata: optional: true "@noxcturnal/noxcturnal-win32-x64-msvc": optional: true - checksum: 10c0/2303a30a46857795257f32d6fb26a973233b6b88c5c5635a7b27fccb0fad19acc0385e2a0e4c0c4ac6d6857c548e42ac95c4d297c1989f9eb7e3b4250f162424 + checksum: 10c0/b27d3ffbd5d1b3af2be55db6c4cb9eff354afc41edbf383fb090231bd4452b807b140d90738b8aca77de92d22ad0b5b1f0a77e0e770d5c9bc4ebbe1eafc099ee languageName: node linkType: hard @@ -10770,17 +10978,22 @@ __metadata: "@babel/core": "npm:^7.29.7" "@babel/preset-env": "npm:^7.29.7" "@babel/runtime": "npm:^7.29.7" - "@react-native/metro-config": "npm:^0.88.0" + "@react-native/metro-config": "npm:^0.88.1" "@react-navigation/native": "npm:7.2.5" "@react-navigation/native-stack": "npm:7.16.0" "@types/react": "npm:~19.3.0" "@webgpu/types": "npm:0.1.70" babel-preset-expo: "npm:~58.0.0" - expo: "npm:~58.0.16" + expo: "npm:~58.0.21" + expo-blur: "npm:~58.0.5" expo-build-properties: "npm:~58.0.11" + expo-camera: "npm:~58.0.0" + expo-glass-effect: "npm:~58.0.5" expo-sensors: "npm:~58.0.4" + expo-splash-screen: "npm:~58.0.12" + expo-symbols: "npm:~58.0.4" react: "npm:19.3.0" - react-native: "npm:0.88.0" + react-native: "npm:0.88.1" react-native-audio-api: "npm:^0.13.6" react-native-effects: "workspace:*" react-native-gesture-handler: "npm:~3.3.0" @@ -10802,9 +11015,9 @@ __metadata: "@eslint/compat": "npm:^2.1.0" "@eslint/eslintrc": "npm:^3.3.5" "@eslint/js": "npm:^9.39.4" - "@react-native/babel-preset": "npm:0.88.0" - "@react-native/eslint-config": "npm:0.88.0" - "@react-native/jest-preset": "npm:0.88.0" + "@react-native/babel-preset": "npm:0.88.1" + "@react-native/eslint-config": "npm:0.88.1" + "@react-native/jest-preset": "npm:0.88.1" "@release-it/conventional-changelog": "npm:^11.0.1" "@types/jest": "npm:^29.5.14" "@types/react": "npm:^19.3.0" @@ -10818,7 +11031,7 @@ __metadata: lefthook: "npm:^2.1.9" prettier: "npm:^3.8.3" react: "npm:19.3.0" - react-native: "npm:0.88.0" + react-native: "npm:0.88.1" react-native-builder-bob: "npm:^0.43.2" react-native-gesture-handler: "npm:3.3.0" react-native-reanimated: "npm:4.7.1" @@ -10967,22 +11180,22 @@ __metadata: languageName: node linkType: hard -"react-native@npm:0.88.0": - version: 0.88.0 - resolution: "react-native@npm:0.88.0" +"react-native@npm:0.88.1": + version: 0.88.1 + resolution: "react-native@npm:0.88.1" dependencies: - "@react-native/asset-utils": "npm:0.88.0" - "@react-native/codegen": "npm:0.88.0" - "@react-native/community-cli-plugin": "npm:0.88.0" - "@react-native/gradle-plugin": "npm:0.88.0" - "@react-native/normalize-colors": "npm:0.88.0" - "@react-native/virtualized-lists": "npm:0.88.0" + "@react-native/asset-utils": "npm:0.88.1" + "@react-native/codegen": "npm:0.88.1" + "@react-native/community-cli-plugin": "npm:0.88.1" + "@react-native/gradle-plugin": "npm:0.88.1" + "@react-native/normalize-colors": "npm:0.88.1" + "@react-native/virtualized-lists": "npm:0.88.1" anser: "npm:^1.4.9" ansi-regex: "npm:^5.0.0" base64-js: "npm:^1.5.1" flow-enums-runtime: "npm:^0.0.6" flow-parser: "npm:0.327.0" - hermes-compiler: "npm:260318099.0.4" + hermes-compiler: "npm:260318099.0.5" invariant: "npm:^2.2.4" memoize-one: "npm:^5.0.0" metro-runtime: "npm:^0.87.1" @@ -11008,7 +11221,7 @@ __metadata: optional: true bin: react-native: cli.js - checksum: 10c0/f1febbfd0fa4ede0656c6b5ff6c2c3d68a075337b2dc91f3b42b4fd89168e197fa6d926b1acf667371c3fb3187e0570b34e7d72803be0e92f0bf995f1d37afd2 + checksum: 10c0/c000040ac4b1b2dc736f40811ef4d11c88986296147e7722ed0f817540c0cb950a322e8766f9bc997470ef01e6bc46e90ff58ed67255fe08e1d8dee7da9ecfb3 languageName: node linkType: hard @@ -11503,7 +11716,7 @@ __metadata: languageName: node linkType: hard -"sf-symbols-typescript@npm:^2.1.0": +"sf-symbols-typescript@npm:^2.0.0, sf-symbols-typescript@npm:^2.1.0": version: 2.2.0 resolution: "sf-symbols-typescript@npm:2.2.0" checksum: 10c0/3f3bbf33aaad19e619d6f169899b39e9fe9c5fd21f0d6d511100e36887606ad349109ddc6ff82933f2b8cbf437dd7105c2ae6b0059b291dc47f143b30c2074cc @@ -11595,7 +11808,7 @@ __metadata: languageName: node linkType: hard -"simple-plist@npm:^1.1.0": +"simple-plist@npm:^1.3.1": version: 1.4.0 resolution: "simple-plist@npm:1.4.0" dependencies: @@ -12085,6 +12298,13 @@ __metadata: languageName: node linkType: hard +"tagged-tag@npm:^1.0.0": + version: 1.0.0 + resolution: "tagged-tag@npm:1.0.0" + checksum: 10c0/91d25c9ffb86a91f20522cefb2cbec9b64caa1febe27ad0df52f08993ff60888022d771e868e6416cf2e72dab68449d2139e8709ba009b74c6c7ecd4000048d1 + languageName: node + linkType: hard + "tar@npm:^7.5.4": version: 7.5.9 resolution: "tar@npm:7.5.9" @@ -12278,6 +12498,15 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^5.10.0": + version: 5.10.0 + resolution: "type-fest@npm:5.10.0" + dependencies: + tagged-tag: "npm:^1.0.0" + checksum: 10c0/313c5d0e37a3f1cc82e942f3228f897623c8b54a63a8a95532d0c2c21ab200793caa70bd8904a83fd713eb28d87ba32468b09b345ef6efa25cecb345b467226f + languageName: node + linkType: hard + "typed-array-buffer@npm:^1.0.3": version: 1.0.3 resolution: "typed-array-buffer@npm:1.0.3" @@ -12539,15 +12768,6 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^7.0.3": - version: 7.0.3 - resolution: "uuid@npm:7.0.3" - bin: - uuid: dist/bin/uuid - checksum: 10c0/2eee5723b0fcce8256f5bfd3112af6c453b5471db00af9c3533e3d5a6e57de83513f9a145a570890457bd7abf2c2aa05797291d950ac666e5a074895dc63168b - languageName: node - linkType: hard - "v8-to-istanbul@npm:^9.0.1": version: 9.3.0 resolution: "v8-to-istanbul@npm:9.3.0" @@ -12828,13 +13048,12 @@ __metadata: languageName: node linkType: hard -"xcode@npm:^3.0.1": - version: 3.0.1 - resolution: "xcode@npm:3.0.1" +"xcode@npm:^4.0.0": + version: 4.0.0 + resolution: "xcode@npm:4.0.0" dependencies: - simple-plist: "npm:^1.1.0" - uuid: "npm:^7.0.3" - checksum: 10c0/51bf35cee52909aeb18f868ecf9828f93b8042fadf968159320f9f11e757a52e43f6563a53b586986cfe5a34d576f3300c4c0cf1e14300084344ae206eaa53c3 + simple-plist: "npm:^1.3.1" + checksum: 10c0/cbb3510eda03636a1594476978f48eebea88a9d8c8ba506df0366c007581774551bb45ca8b511c6a41fc4b6b564958e765c89ad31d8aabec4e4f8f993c8a7fc6 languageName: node linkType: hard @@ -12969,3 +13188,15 @@ __metadata: checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c languageName: node linkType: hard + +"zxing-wasm@npm:3.1.6": + version: 3.1.6 + resolution: "zxing-wasm@npm:3.1.6" + dependencies: + "@types/emscripten": "npm:^1.41.6" + type-fest: "npm:^5.10.0" + peerDependencies: + "@types/emscripten": ">=1.39.6" + checksum: 10c0/5df631ec1ca2ef53c6c22ffbc0cb4a27f28cf40d120eb64733bfa7c3070e844f46ad1c9c629f345e110c128db6a46afd3663c26f402d360b997f000ca7925387 + languageName: node + linkType: hard