diff --git a/.gitignore b/.gitignore index a3c6b1e..3bfbf75 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,12 @@ nitrogen/ # argent screen recordings .argent/ + +# App Store release +.argent/ +example/goldie/out/ +example/fastlane/report.xml +example/fastlane/README.md +example/fastlane/metadata/review_information/ +example/build/ +example/.bundle/ diff --git a/README.md b/README.md index 893c897..3458082 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ WebGPU shader effects for React Native. Drop in a component, or write your own effect in WGSL. Rendering runs on a separate JS runtime, so a busy JS thread does not stall the animation.

- The example app: the effects catalog, Lava Lamp, Liquid Chrome, Thanos, Bubble Wrap, Ferrofluid, Polaroid and CRT Off + The example app: the effects catalog, Lava Lamp, Liquid Chrome, Dust, Bubble Wrap, Ferrofluid, Instant Camera and CRT Off

> **Experimental.** APIs may change without notice. The library relies on `react-native-worklets` bundle mode, which is off by default for now. diff --git a/docs/app-store/asset-audit.md b/docs/app-store/asset-audit.md index 4a808b6..c6f04a6 100644 --- a/docs/app-store/asset-audit.md +++ b/docs/app-store/asset-audit.md @@ -2,39 +2,34 @@ 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. +## Blockers: resolved on 2026-10-31 + +Every blocker from the audit of 2026-10-30 is out of the app. Licences of the new Commons sources were read from the Commons API on 2026-10-31 and are recorded in `docs/credits.md`. `example/assets/tools/generate-assets.py` rebuilds everything marked as generated. + +| Old asset | Replacement | +| --- | --- | +| `foam.jpg` | Generated foam and beer texture | +| `candle/cake.jpg` | "3rd birthday cake", CC0, flame painted out and background extended | +| `namedrop/home-atlas.jpg` | `wave/scene-atlas.jpg`, "Misty path", CC0, no icons | +| `iphone-background.png` | `scenes/misty-path.jpg`, same scene | +| `wave/avatar.jpg` | `wave/dusk.jpg`, a crop of "Seaside swing at dusk", CC0, with no person in frame | +| `telegram/pattern.png` | Generated dot pattern, `chat/pattern.png` | +| `telegram/*.png`, `instagram/*.png` | Generated icons in `chat/` | +| `telegram/avatar.jpg` | Generated avatar, `chat/avatar-peaks.png` | +| `weather/*.png` | Generated icons at the same paths | +| Runtime Lorem Picsum images | Bundled: "Forest path at sunset" and "Misty path" (both CC0), two generated avatars | +| `catalog/*.jpg` showing other products | Retaken from the app after the screens changed, or deleted with their screen | +| `charizard.png` | Deleted | ## 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. +- **SF Symbols.** The flashlight PNGs are deleted. The screen now uses plain sliders and a text button, so no SF Symbol assets remain. +- **Portrait release.** Black Rock beach shows real people, and Commons flags personality rights on it. CC licences cover copyright only. Keep it out of store screens. - **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`. +Verified against the Commons API on 2026-10-30, author and licence match `docs/credits.md`: `flashlight/corridor.jpg`, `camera/photo-*.jpg`, `camera/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 index 2c88b52..d8f5a7d 100644 --- a/docs/app-store/metadata.md +++ b/docs/app-store/metadata.md @@ -6,15 +6,14 @@ Paste these into App Store Connect. Limits are noted where Apple enforces them. | Field | Value | | --- | --- | -| App name (30) | Effects | -| Subtitle (30) | Shaders you tilt, blow & shake | +| App name (30) | Effects: shaders & fun | +| Subtitle (30) | Toys 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 | @@ -41,7 +40,7 @@ Effects is a pocket collection of interactive visual effects. Every one is a rea Things to try: -- Tap hard and watch the screen glass crack where you hit it +- Tap 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 @@ -52,11 +51,11 @@ There are also fabrics, liquid metal, northern lights, fire, ink and weather ski 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. +Sensor and microphone readings are used live for the animation and stay on your iPhone. The cracks, chats and cards 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 +interactive,physics,particles,satisfying,fidget,toy,art,animation,visual,sensory,relaxing,simulation Leave out every third-party name, including Apple, iPhone, Instagram, Telegram and Polaroid. @@ -84,11 +83,11 @@ Apple's current questionnaire. Answers reflect the catalog as of 2026-10-30. | Realistic violence | None | | | Guns or other weapons | None | | | Gambling | No | No real money | -| Simulated gambling | Infrequent | Scratch-off card with dollar prizes | +| Simulated gambling | None | Scratch-off uses symbols only, with no points, money or wagering | | 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. +Expected result: 13+. Apple maps Frequent alcohol or tobacco references to 18+, and two effects out of about forty supports Infrequent. Confirm the outcome on the Age Ratings page in App Store Connect. Do not tick "Made for Kids". @@ -100,26 +99,33 @@ Do not tick "Made for Kids". 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. +Basis: the app contains no proprietary or non-standard encryption and only uses HTTPS through iOS. `disableFFmpeg` removes the OpenSSL code that FFmpeg would bring in. 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. + Effects is a catalog of interactive real-time visual effects rendered on the GPU. There is no login, no backend, no purchases, no network use and no user-generated content. No demo account is needed. + + All people, chats, cards, banners, balances and forecasts in the app are fictional sample content. Chat screens are scripted animations and send nothing. Card screens make no payments. Weather is a labelled sample forecast. Banners are invented notifications and use no calendar or weather data. AI Orb, AI Shimmer and AI Voice are visual effects and contain no AI service. - 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 (a real iPhone is recommended, because 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 never recorded. Refusing permission leaves the screens usable. + - Camera: open Instant Camera, press Continue, allow the camera, then press the shutter. The photo stays in temporary storage and is not saved or uploaded. + - Motion: tilt in Beer, Hourglass or Lava Lamp. Shake in Instant Camera or Mystic Ball. + - Magnetometer: hold a magnet near the phone in Ferrofluid, or touch and hold the screen if no magnet is available. - 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. + Other notes: + - Cracked Screen and Hinge Crack show a permanent caption saying they are an effect. They end when you go back. + - Hourglass is a working focus timer. + - Flashlight is an on-screen beam and does not use the LED. + - Scratch-off uses symbols and always reveals a match. There is no money, stake or prize. + - Beer is a physics toy with no brand, drinking game or challenge. + - Links on the About screen, including the Privacy Policy, open in the system browser. + - The app is iPhone only and runs in compatibility mode on iPad. Contact: kustrablazej@gmail.com diff --git a/docs/app-store/review-risks.md b/docs/app-store/review-risks.md index 79d6da2..26083dd 100644 --- a/docs/app-store/review-risks.md +++ b/docs/app-store/review-risks.md @@ -107,6 +107,33 @@ Apple allows device frames in marketing within its guidelines, but avoid iPhone ### 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". +## Status on 2026-10-31 + +The owner asked to release, so the fixes below are applied. Where an item above names an effect by its old title, this table gives the new one. + +| Item | Status | What changed | +| --- | --- | --- | +| 1 Wallet | Fixed | Now "Metal Card": a single tap, no payment flow or system phrases, invented mark, app-styled sheet, no card stack. The glint shader is unchanged | +| 2 Home screens | Fixed | No app-icon grid remains. Glow Orb sits on a CC0 misty path photo and Wave Reveal carries that scene to a dusk photo | +| 3 Telegram screens | Fixed | Now "Message Dissolve" and "Hidden Text", with redrawn icons, an original wallpaper and an invented chat style | +| 4 Voice Delete | Fixed | Restyled, redrawn icons, four bundled images, no network | +| 5 iMessage Effects | Fixed | Now "Message Flourishes", with teal bubbles, a flat top bar and no call button. Encore is now rings rippling out of the bubble and Beams is now warm daylight shafts, so neither matches Echo or Lasers | +| 6 NameDrop | Fixed | Now "Wave Reveal", an animation from a misty morning scene to a dusk photo. No contact poster, no real person, no share choice | +| 7 Cracked Screen, Crackgate | Fixed | Abstract scene with an invented clock and a persistent caption. Crackgate is now "Hinge Crack" and the product name is gone | +| 8 Thanos | Fixed | Now "Dust" | +| 9 Weather | Fixed | New copy, labelled "Sample forecast", left-aligned layout, solid range bars, redrawn icons | +| 10 Orbs, Banners | Fixed | "Glow Orb" is a centred gold and jade orb with a soft halo and no screen-edge glow, "Morphing thinking blob", invented app name in the banner, new Banners line | +| 11 Cards | Fixed | Invented issuers, obviously fake numbers, contactless mark removed | +| 12 Trademarked titles | Fixed | "Instant Camera" with a neutral stripe, "Mystic Ball" with a 7, "The Eye" with new copy | +| 13 Positioning | Fixed in the catalog | Beer Foam (Foam lab), Linear Gradient, Circular Gradient and Scroll Reactive removed. A reviewer may still call the app a tech demo | +| 14 App name | Open | Unchanged: "Effects" | +| 15, 16 Lighter, Beer | Open | Age rating answers, and keep Beer out of screenshots | +| 17 Scratch-off | Fixed | Now "Lucky Clover" in the UI, with symbols only: no points, bonus strip or ticket price. The simulated gambling answer can be None | +| 18 Jokes and names | Fixed | Invented people, everyday chat scripts, developer in-jokes removed | +| 19 Device frames | Open | Nothing changed. Applies to marketing images | +| 20 Flashlight | Fixed | New copy. The SF Symbol PNGs are deleted and the screen uses plain sliders and a switch | +| 21 Permissions | No change needed | Already verified | + ## Fixed in the repository (Applied) | Risk | Change | @@ -122,6 +149,6 @@ Dandelion and Birthday Candle ask for the microphone as soon as they open. Apple ## 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`. +- **Portrait lock:** `app.json` now sets portrait, since tilt effects would otherwise rotate the layout mid-gesture. Hinge Crack replays on a tap. Rotating no longer replays it. +- **FFmpeg:** `"disableFFmpeg": true` is set on the react-native-audio-api plugin. The Release build on 2026-10-31 has no FFmpeg frameworks. +- **Barcode scanning:** `"barcodeScannerEnabled": false` is set on expo-camera. See `docs/credits.md`. diff --git a/docs/credits.md b/docs/credits.md index 38cd262..4792cac 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -1,6 +1,6 @@ # Effects credits and licenses -Effects uses the third-party photographs and open-source software listed here. Thank you to their authors. +Effects uses the third-party photographs and open-source software listed here. Everything else in the app, including icons, chat wallpapers, weather symbols and the foam texture, is original and drawn in code or by `example/assets/tools/generate-assets.py`. 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. @@ -10,28 +10,26 @@ All from Wikimedia Commons. Each is cropped, resized or otherwise adapted for th | 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/) | +| Instant Camera | `camera/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/) | +| Instant Camera | `camera/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/) | +| Instant Camera | `camera/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/) | +| Instant Camera, Bubble Wrap | `camera/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/) | +| Hinge Crack | `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, Wave Reveal | `cracks/swing.jpg`, cropped for `wave/dusk.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. +| Birthday Candle | `candle/cake.jpg` | [3rd birthday cake](https://commons.wikimedia.org/wiki/File:3rd_birthday_cake_(Unsplash_10b8Lvvc-4g).jpg), background extended and flame painted out | Annie Spratt | [CC0](https://creativecommons.org/publicdomain/zero/1.0/) | +| Voice Delete | `chat/photo-post.jpg` | [Forest path at sunset](https://commons.wikimedia.org/wiki/File:Forest_path_at_sunset_(Unsplash).jpg) | Sergei Akulich | [CC0](https://creativecommons.org/publicdomain/zero/1.0/) | +| Voice Delete, Glow Orb, Wave Reveal | `chat/photo-reel.jpg`, `scenes/misty-path.jpg` and `wave/scene-atlas.jpg` | [Misty path](https://commons.wikimedia.org/wiki/File:Misty_path_(Unsplash).jpg) | Alessio Lin | [CC0](https://creativecommons.org/publicdomain/zero/1.0/) | ## 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 | @@ -42,7 +40,6 @@ Lorem Picsum (picsum.photos) serves the four placeholder pictures in the Voice D | 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 @@ -52,18 +49,20 @@ Taken from the production bundle's source map. MIT and ISC packages are distribu | 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 | +| @expo/cli | 58.1.17 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| @expo/log-box-utils | 58.0.2 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| @expo/metro | 58.0.0 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | +| @react-native/asset-utils | 0.88.1 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | +| @react-native/js-polyfills | 0.88.1 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates | +| @react-native/normalize-colors | 0.88.1 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates | +| @react-native/virtualized-lists | 0.88.1 | 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 | +| ansi-styles | 4.3.0 | MIT | Copyright (c) Sindre Sorhus (https://sindresorhus.com) | | 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 | @@ -71,39 +70,42 @@ Taken from the production bundle's source map. MIT and ISC packages are distribu | 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) | +| expo | 58.0.21 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| expo-asset | 58.0.19 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| expo-blur | 58.0.5 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| expo-camera | 58.0.14 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| expo-constants | 58.0.14 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| expo-glass-effect | 58.0.5 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| expo-modules-core | 58.0.28 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| expo-sensors | 58.0.4 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | +| expo-symbols | 58.0.4 | 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 | +| metro-runtime | 0.87.1 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | | nanoid | 3.3.12 | MIT | Copyright 2017 Andrey Sitnik | | nullthrows | 1.1.1 | MIT | Copyright (c) 2016 Andres Suarez | +| pretty-format | 29.7.0 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | | 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 | 19.3.0 | 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 | 0.88.1 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | +| react-native-audio-api | 0.13.6 | MIT | Copyright (c) Software Mansion | +| react-native-gesture-handler | 3.3.0 | 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 | +| react-native-reanimated | 4.7.1 | MIT | Copyright (c) 2016 Software Mansion | +| react-native-safe-area-context | 5.10.1 | MIT | Copyright (c) 2019 Th3rd Wave | +| react-native-screens | 4.30.0 | MIT | Copyright (c) 2018 Software Mansion | +| react-native-view-shot | 6.1.1 | MIT | Copyright (c) 2016 Gaëtan Renaudeau | +| react-native-webgpu | 0.17.0 | MIT | Copyright (c) 2024 William Candillon | +| react-native-worklets | 0.13.0 | 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. | +| scheduler | 0.28.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) | @@ -113,7 +115,7 @@ Taken from the production bundle's source map. MIT and ISC packages are distribu | 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, | +| whatwg-url-minimum | 0.2.0 | MIT | Copyright (c) Phil Pluckthun, | ## MIT License text diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md index 7dcb2b0..0a6063d 100644 --- a/docs/privacy-policy.md +++ b/docs/privacy-policy.md @@ -1,6 +1,6 @@ # Effects Privacy Policy -Effective date: 2026-10-30 +Effective date: 2026-10-31 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. @@ -20,7 +20,7 @@ The app reads the sound level and frequency of the live signal to drive the anim ### 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 Instant Camera 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. @@ -34,9 +34,7 @@ Some effects take a picture of their own screen content to animate it, for examp ## 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. +The app makes no network requests of its own. Everything it shows is bundled with it, and it works offline. Links on the About screen open in your browser, where the website's own privacy policy applies. diff --git a/eslint.config.mjs b/eslint.config.mjs index 7c5878d..64125b4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,6 +25,6 @@ export default defineConfig([ }, }, { - ignores: ['node_modules/', 'lib/'], + ignores: ['node_modules/', 'lib/', 'example/vendor/'], }, ]); diff --git a/example/Gemfile b/example/Gemfile new file mode 100644 index 0000000..7a118b4 --- /dev/null +++ b/example/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/example/Gemfile.lock b/example/Gemfile.lock new file mode 100644 index 0000000..d604fc1 --- /dev/null +++ b/example/Gemfile.lock @@ -0,0 +1,397 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.1) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.1) + aws-partitions (1.1300.0) + aws-sdk-core (3.263.0) + aws-eventstream (~> 1, >= 1.4.1) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + rexml (~> 3.4, >= 3.4.2) + aws-sdk-kms (1.138.0) + aws-sdk-core (~> 3, >= 3.263.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.237.0) + aws-sdk-core (~> 3, >= 3.263.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.3) + cgi (0.5.2) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + csv (3.3.7) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.7.20261007) + dotenv (2.8.1) + emoji_regex (3.2.3) + erb (6.0.7) + excon (1.10.0) + faraday (2.15.1) + faraday-net_http (>= 2.0, < 3.6) + json + logger + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (3.5.0) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) + fastimage (2.4.3) + fastlane (2.241.2) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) + addressable (>= 2.9.0, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.197) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) + cgi (~> 0.4) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + erb (>= 4.0, < 7.0) + excon (>= 1.0.0, < 2.0.0) + faraday (~> 2.7) + faraday-cookie_jar (~> 0.0.8) + faraday-follow_redirects (~> 0.3) + faraday-multipart (~> 1.0) + faraday-retry (~> 2.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.99) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.5.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + irb (>= 1.8) + json (< 3.0.0) + jwt (>= 2.10.3, < 4) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + multi_json (~> 1.12) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) + naturally (~> 2.2) + nkf (~> 0.2) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 3.4.0, < 4.0.0) + security (~> 0.3) + simctl (~> 1.6.3) + terminal-notifier (>= 3.1.0, < 4.0.0) + terminal-table (~> 4) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.110.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (1.4.0) + addressable (~> 2.9) + faraday (~> 2.13) + faraday-follow_redirects (~> 0.3) + googleauth (~> 1.14) + mini_mime (~> 1.1) + multi_json (~> 1.11) + representable (~> 3.0) + retriable (>= 3.1, < 5.0) + google-apis-iamcredentials_v1 (0.28.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.18.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.70.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.9.1) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.4.1) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.7.1) + google-cloud-storage (1.62.1) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + google-logging-utils (0.2.2) + googleauth (1.17.7) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + os (>= 0.9, < 2.0) + pstore (~> 0.1) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.1.6) + domain_name (~> 0.5) + io-console (0.9.5) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jmespath (1.6.2) + json (2.21.2) + jwt (3.3.0) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.21.2) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + net-http (0.10.0) + net-protocol (>= 0.3.0) + uri (>= 0.11.1) + net-protocol (0.4.0) + timeout + nkf (0.3.0) + optparse (0.8.1) + os (1.1.4) + ostruct (0.6.3) + plist (3.7.2) + pp (0.6.4) + prettyprint + prettyprint (0.2.1) + prism (2.1.0) + prism (2.1.0-aarch64-linux-gnu) + prism (2.1.0-aarch64-linux-musl) + prism (2.1.0-arm-linux-gnu) + prism (2.1.0-arm-linux-musl) + prism (2.1.0-arm64-darwin) + prism (2.1.0-x86_64-darwin) + prism (2.1.0-x86_64-linux-gnu) + prism (2.1.0-x86_64-linux-musl) + pstore (0.3.0) + public_suffix (7.0.5) + rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.1.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) + representable (3.3.1) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (4.2.0) + rexml (3.4.4) + rouge (3.28.0) + rubyzip (3.7.0) + security (0.4.0) + signet (0.22.2) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (3.1.0) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) + timeout (0.6.1) + trailblazer-option (0.1.2) + tsort (0.2.0) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (3.3.0) + unicode-emoji (~> 4.3) + unicode-emoji (4.3.0) + uri (1.1.2) + word_wrap (1.0.0) + xcodeproj (1.28.1) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + base64 + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + nkf + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + ruby + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + fastlane + +CHECKSUMS + CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261 + abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242 + addressable (2.9.1) sha256=f1212365945455c84e8d031eea40fc6bedf0c663a78d95b3ab93591040bc5c35 + artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263 + atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f + aws-eventstream (1.4.1) sha256=ceb6a99c61df4bb00344e54aed0351574beca24f87a0d1487d1d2ed80929ce96 + aws-partitions (1.1300.0) sha256=e3cc87a4ffe875d7326e6b9f5fe696ff9a59632227f3252061dcba1782664924 + aws-sdk-core (3.263.0) sha256=9b6d396ed8b3e5ba19b0640fbdbfa1875b0c485353e1bb836f1e72b403c8892d + aws-sdk-kms (1.138.0) sha256=b3b4da50b962faa1333d6072f2eb04d8ad71569b32ee0d639657df3f8be8b37d + aws-sdk-s3 (1.237.0) sha256=1e8a96a5411124d66f85f397bd00a1d6df53da1946725b6d5980d921a8e11191 + aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 + babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c + bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66 + bundler (4.0.23) sha256=407ba8a1d928f412847f819aa5f8b6aef7c59fb51feb517112af8a165487287b + cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb + claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e + colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c + colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a + commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9 + csv (3.3.7) sha256=15a3f16f6628da8664d455eb634e7f22031bcf53044923caf56975109e71b9f8 + declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9 + digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07 + domain_name (0.7.20261007) sha256=0976c6ab837dbde991b433d03acc38fdb91fa8839df31a1ed75ce0318dc0441c + dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 + emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + excon (1.10.0) sha256=868d456c6db29f965d7cc6c5eddbb4d77df08e2a2b2dca7ca3547f2a55afc4b4 + faraday (2.15.1) sha256=8708154deda739c5e86cc42b74960e23e8491fce0eba9e11639a6a34b1ac288f + faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb + faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad + faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 + faraday-net_http (3.5.0) sha256=2e7094c34e38754ee048cc3df47376f8657005d9375f8473bce1c3b526a22e36 + faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe + fastimage (2.4.3) sha256=304457e3c35bfdaefda3f1dacab2222a96b230b8ce6977b6b54748c1cc7ec539 + fastlane (2.241.2) sha256=9c23f6d1b4aa5d8570ff8584186d5fdb1fdf7a01dd7ad168501b43fe360fe122 + fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 + gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 + google-apis-androidpublisher_v3 (0.110.0) sha256=0b0b84af02fb7f3a062849bc60d545ff8b37670ea7e90bd947199cc671916513 + google-apis-core (1.4.0) sha256=74e838a334926e79ce685d3ff9f418956cc23e48c5fd7cade779fea933e96582 + google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106 + google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254 + google-apis-storage_v1 (0.70.0) sha256=6a2925cc39c27a133239738130a4cf37601e00627a36d91e4f528f5a4620ad0c + google-cloud-core (1.9.1) sha256=6590e393cbeadf018e6880038e19348d2ccfc83000625c569a9b21ab3b450af0 + google-cloud-env (2.4.1) sha256=987cc58afc50ce2903128ab4dcd442159be4457246b8137e41d49d5c3fc590dd + google-cloud-errors (1.7.1) sha256=818bcf30998d10f28f30532a2595b49438cb7abf42f6ac495843078839987207 + google-cloud-storage (1.62.1) sha256=10dbf2ebda3af1b4b841335364cb889827c6cb17bf6c9f8bc5313b6bef7a4d3c + google-logging-utils (0.2.2) sha256=ad2436ef22e918e2b1c3ad2bbaf5949f42f5389d49fd37e41b43bbdd3068e27c + googleauth (1.17.7) sha256=9db21857ddbe055a8b0d44ddb75d4ceda97511167431e30121c77c25a9ce9fe5 + highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479 + http-cookie (1.1.6) sha256=ba4b82be64de61dc281243dac70e3c382c45142f20268ed9276a3670c93feaa9 + io-console (0.9.5) sha256=83dd9bbf4c4680416629245b39cd4212eb5f4d9a4ea91804870c1f6b846414d5 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + multi_json (1.21.2) sha256=245531eaf54bdba57aba515ff08b4503505c5e09911daa89c5a561e2f85bea8e + multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 + naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 + net-http (0.10.0) sha256=1a2ff6e37a05724973c43f2bf7bf105f59c36a4cd0a7e9b1c021a0a566987c27 + net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853 + nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895 + optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a + os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.1) sha256=d555d57ea6d7cf001d4774b693546a6bd88bec1e778ddffb5c60b68ceec4b7d4 + prism (2.1.0) sha256=1c0a7c6dea0e93183390fb123f007564dbe663198e2f86ad87204151147601f3 + prism (2.1.0-aarch64-linux-gnu) sha256=808dddc72def68e1a0463689414c97e0ba211938eddbab05387a4844aa2b3ec2 + prism (2.1.0-aarch64-linux-musl) sha256=dd1a0732620c1e575dabb07a297a62911fabd72f8e20ce850b522a866f877930 + prism (2.1.0-arm-linux-gnu) sha256=96b2426b9dbab05448c4add735619929042be3cd7e871ff186468121b62bcfa8 + prism (2.1.0-arm-linux-musl) sha256=00ef918e42a3dc98f11a461bf473586a3424b0f78bee8a4e43b5a65a5f5e2cd0 + prism (2.1.0-arm64-darwin) sha256=2d0c2e3e011cfd89c041940907d3ff3def379d7b0f2ba158d1d42238e8ad8afc + prism (2.1.0-x86_64-darwin) sha256=6c645d8e6afc742956c7518220ce5106367727c4d9980ac9ab386d55004ddab4 + prism (2.1.0-x86_64-linux-gnu) sha256=b2b647afce09551e650b8bb3e2fc91d7d73d392a09534ca307ec0d619c33bfd4 + prism (2.1.0-x86_64-linux-musl) sha256=249f45981726923e259b927902246219fa25ad10a76f43849c9aee1a3d3ec5b8 + pstore (0.3.0) sha256=97888d57cdc8b99b3587dde5e1d01fc5ca4a9124dfeb2afb84e3dd982e78f829 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + representable (3.3.1) sha256=8a5fc8eef99f598147eee0390f606386268b429a840419fcfd3f7a8ccc40f566 + retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20 + rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121 + security (0.4.0) sha256=fd3fdbd2e50e45616082dbeec0bf0813b1134b2420a034ad35c3af2807f3fdbc + signet (0.22.2) sha256=9c6861bb599845ea0cb05ffadd3d64c7a3b07a29bdee0f5352bcbe87b7ade21d + simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b + terminal-notifier (3.1.0) sha256=073609e9da7bb60176ffadda903c8d9231fcdf18ffa7074dc9478b63fe25c69c + terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 + tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 + tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542 + uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc + unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613 + unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce + uri (1.1.2) sha256=89292e98eabbef6248b0a0ed5e7e398e152f83f17560023a6d94a7f492b169fb + word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7 + xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc + xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892 + xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93 + +BUNDLED WITH + 4.0.23 diff --git a/example/app.json b/example/app.json index 5079e0f..5cd4cf2 100644 --- a/example/app.json +++ b/example/app.json @@ -69,13 +69,15 @@ "react-native-audio-api", { "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 + "iosBackgroundMode": false, + "disableFFmpeg": true } ], [ "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.", + "barcodeScannerEnabled": false, "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." } ], diff --git a/example/assets/polaroid/photo-beach.jpg b/example/assets/camera/photo-beach.jpg similarity index 100% rename from example/assets/polaroid/photo-beach.jpg rename to example/assets/camera/photo-beach.jpg diff --git a/example/assets/polaroid/photo-lake.jpg b/example/assets/camera/photo-lake.jpg similarity index 100% rename from example/assets/polaroid/photo-lake.jpg rename to example/assets/camera/photo-lake.jpg diff --git a/example/assets/polaroid/photo-mountains.jpg b/example/assets/camera/photo-mountains.jpg similarity index 100% rename from example/assets/polaroid/photo-mountains.jpg rename to example/assets/camera/photo-mountains.jpg diff --git a/example/assets/polaroid/table.jpg b/example/assets/camera/table.jpg similarity index 100% rename from example/assets/polaroid/table.jpg rename to example/assets/camera/table.jpg diff --git a/example/assets/candle/cake.jpg b/example/assets/candle/cake.jpg index bdf6d2a..15c788c 100644 Binary files a/example/assets/candle/cake.jpg and b/example/assets/candle/cake.jpg differ diff --git a/example/assets/catalog/ai-shimmer.jpg b/example/assets/catalog/ai-shimmer.jpg index 73dfd93..94bc4d0 100644 Binary files a/example/assets/catalog/ai-shimmer.jpg and b/example/assets/catalog/ai-shimmer.jpg differ diff --git a/example/assets/catalog/banners.jpg b/example/assets/catalog/banners.jpg index f578687..81061c0 100644 Binary files a/example/assets/catalog/banners.jpg and b/example/assets/catalog/banners.jpg differ diff --git a/example/assets/catalog/beer.jpg b/example/assets/catalog/beer.jpg index 3dd2324..5fdb7c4 100644 Binary files a/example/assets/catalog/beer.jpg 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 index eaf0c37..0c7158f 100644 Binary files a/example/assets/catalog/birthday-candle-hero.jpg 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 index fd610d0..a8f9c92 100644 Binary files a/example/assets/catalog/birthday-candle.jpg and b/example/assets/catalog/birthday-candle.jpg differ diff --git a/example/assets/catalog/circular-gradient.jpg b/example/assets/catalog/circular-gradient.jpg deleted file mode 100644 index 8bf07c2..0000000 Binary files a/example/assets/catalog/circular-gradient.jpg and /dev/null differ diff --git a/example/assets/catalog/cracked-screen-hero.jpg b/example/assets/catalog/cracked-screen-hero.jpg index 44293ea..4a7e677 100644 Binary files a/example/assets/catalog/cracked-screen-hero.jpg 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 index 548c3e4..6b25b53 100644 Binary files a/example/assets/catalog/cracked-screen.jpg and b/example/assets/catalog/cracked-screen.jpg differ diff --git a/example/assets/catalog/dandelion-hero.jpg b/example/assets/catalog/dandelion-hero.jpg new file mode 100644 index 0000000..d9d0e18 Binary files /dev/null and b/example/assets/catalog/dandelion-hero.jpg differ diff --git a/example/assets/catalog/duo-crackgate.jpg b/example/assets/catalog/duo-crackgate.jpg deleted file mode 100644 index 2074552..0000000 Binary files a/example/assets/catalog/duo-crackgate.jpg and /dev/null differ diff --git a/example/assets/catalog/thanos.jpg b/example/assets/catalog/dust.jpg similarity index 100% rename from example/assets/catalog/thanos.jpg rename to example/assets/catalog/dust.jpg diff --git a/example/assets/catalog/flashlight.jpg b/example/assets/catalog/flashlight.jpg index 82aee33..7d1e919 100644 Binary files a/example/assets/catalog/flashlight.jpg and b/example/assets/catalog/flashlight.jpg differ diff --git a/example/assets/catalog/foamlab.jpg b/example/assets/catalog/foamlab.jpg deleted file mode 100644 index fed123e..0000000 Binary files a/example/assets/catalog/foamlab.jpg and /dev/null differ diff --git a/example/assets/catalog/glow-orb.jpg b/example/assets/catalog/glow-orb.jpg new file mode 100644 index 0000000..69495b2 Binary files /dev/null and b/example/assets/catalog/glow-orb.jpg differ diff --git a/example/assets/catalog/hidden-text.jpg b/example/assets/catalog/hidden-text.jpg new file mode 100644 index 0000000..7f53a49 Binary files /dev/null and b/example/assets/catalog/hidden-text.jpg differ diff --git a/example/assets/catalog/hinge-crack.jpg b/example/assets/catalog/hinge-crack.jpg new file mode 100644 index 0000000..f156142 Binary files /dev/null and b/example/assets/catalog/hinge-crack.jpg differ diff --git a/example/assets/catalog/holo-card.jpg b/example/assets/catalog/holo-card.jpg index 7376c0a..1ca80c6 100644 Binary files a/example/assets/catalog/holo-card.jpg and b/example/assets/catalog/holo-card.jpg differ diff --git a/example/assets/catalog/imessage.jpg b/example/assets/catalog/imessage.jpg deleted file mode 100644 index 924604a..0000000 Binary files a/example/assets/catalog/imessage.jpg and /dev/null differ diff --git a/example/assets/catalog/polaroid.jpg b/example/assets/catalog/instant-camera.jpg similarity index 100% rename from example/assets/catalog/polaroid.jpg rename to example/assets/catalog/instant-camera.jpg diff --git a/example/assets/catalog/lighter-hero.jpg b/example/assets/catalog/lighter-hero.jpg deleted file mode 100644 index 44bbaab..0000000 Binary files a/example/assets/catalog/lighter-hero.jpg and /dev/null differ diff --git a/example/assets/catalog/linear-gradient-animated.jpg b/example/assets/catalog/linear-gradient-animated.jpg deleted file mode 100644 index 20f685d..0000000 Binary files a/example/assets/catalog/linear-gradient-animated.jpg and /dev/null differ diff --git a/example/assets/catalog/linear-gradient.jpg b/example/assets/catalog/linear-gradient.jpg deleted file mode 100644 index ebf0485..0000000 Binary files a/example/assets/catalog/linear-gradient.jpg and /dev/null differ diff --git a/example/assets/catalog/magic-8-ball.jpg b/example/assets/catalog/magic-8-ball.jpg deleted file mode 100644 index e07606f..0000000 Binary files a/example/assets/catalog/magic-8-ball.jpg and /dev/null differ diff --git a/example/assets/catalog/message-dissolve.jpg b/example/assets/catalog/message-dissolve.jpg new file mode 100644 index 0000000..c08814c Binary files /dev/null and b/example/assets/catalog/message-dissolve.jpg differ diff --git a/example/assets/catalog/metal-card.jpg b/example/assets/catalog/metal-card.jpg new file mode 100644 index 0000000..8675ddc Binary files /dev/null and b/example/assets/catalog/metal-card.jpg differ diff --git a/example/assets/catalog/mystic-ball.jpg b/example/assets/catalog/mystic-ball.jpg new file mode 100644 index 0000000..ff5ca36 Binary files /dev/null and b/example/assets/catalog/mystic-ball.jpg differ diff --git a/example/assets/catalog/namedrop.jpg b/example/assets/catalog/namedrop.jpg deleted file mode 100644 index 69fc692..0000000 Binary files a/example/assets/catalog/namedrop.jpg and /dev/null differ diff --git a/example/assets/catalog/scratch-off.jpg b/example/assets/catalog/scratch-off.jpg index fa0036b..ac75ad0 100644 Binary files a/example/assets/catalog/scratch-off.jpg 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 deleted file mode 100644 index 4524e25..0000000 Binary files a/example/assets/catalog/scroll-reactive.jpg and /dev/null differ diff --git a/example/assets/catalog/send-effects.jpg b/example/assets/catalog/send-effects.jpg new file mode 100644 index 0000000..2a38d85 Binary files /dev/null and b/example/assets/catalog/send-effects.jpg differ diff --git a/example/assets/catalog/siri-orb.jpg b/example/assets/catalog/siri-orb.jpg deleted file mode 100644 index a641c72..0000000 Binary files a/example/assets/catalog/siri-orb.jpg and /dev/null differ diff --git a/example/assets/catalog/telegram-dissolve.jpg b/example/assets/catalog/telegram-dissolve.jpg deleted file mode 100644 index 23dd0ef..0000000 Binary files a/example/assets/catalog/telegram-dissolve.jpg and /dev/null differ diff --git a/example/assets/catalog/telegram-spoiler.jpg b/example/assets/catalog/telegram-spoiler.jpg deleted file mode 100644 index e041bd9..0000000 Binary files a/example/assets/catalog/telegram-spoiler.jpg and /dev/null differ diff --git a/example/assets/catalog/voice-delete.jpg b/example/assets/catalog/voice-delete.jpg index 12609f0..cc31d92 100644 Binary files a/example/assets/catalog/voice-delete.jpg and b/example/assets/catalog/voice-delete.jpg differ diff --git a/example/assets/catalog/wallet.jpg b/example/assets/catalog/wallet.jpg deleted file mode 100644 index 6524e99..0000000 Binary files a/example/assets/catalog/wallet.jpg and /dev/null differ diff --git a/example/assets/catalog/wave-reveal.jpg b/example/assets/catalog/wave-reveal.jpg new file mode 100644 index 0000000..c195cca Binary files /dev/null and b/example/assets/catalog/wave-reveal.jpg differ diff --git a/example/assets/catalog/weather.jpg b/example/assets/catalog/weather.jpg index 300cb00..2a01e1c 100644 Binary files a/example/assets/catalog/weather.jpg and b/example/assets/catalog/weather.jpg differ diff --git a/example/assets/charizard.png b/example/assets/charizard.png deleted file mode 100644 index 2ba382c..0000000 Binary files a/example/assets/charizard.png and /dev/null differ diff --git a/example/assets/chat/avatar-bars.png b/example/assets/chat/avatar-bars.png new file mode 100644 index 0000000..e64f086 Binary files /dev/null and b/example/assets/chat/avatar-bars.png differ diff --git a/example/assets/chat/avatar-peaks.png b/example/assets/chat/avatar-peaks.png new file mode 100644 index 0000000..08ceabf Binary files /dev/null and b/example/assets/chat/avatar-peaks.png differ diff --git a/example/assets/chat/avatar-rings.png b/example/assets/chat/avatar-rings.png new file mode 100644 index 0000000..b4b9714 Binary files /dev/null and b/example/assets/chat/avatar-rings.png differ diff --git a/example/assets/chat/back.png b/example/assets/chat/back.png new file mode 100644 index 0000000..b4342f9 Binary files /dev/null and b/example/assets/chat/back.png differ diff --git a/example/assets/chat/copy.png b/example/assets/chat/copy.png new file mode 100644 index 0000000..f57d293 Binary files /dev/null and b/example/assets/chat/copy.png differ diff --git a/example/assets/chat/mic.png b/example/assets/chat/mic.png new file mode 100644 index 0000000..17e5c6f Binary files /dev/null and b/example/assets/chat/mic.png differ diff --git a/example/assets/chat/pattern.png b/example/assets/chat/pattern.png new file mode 100644 index 0000000..ac69dd5 Binary files /dev/null and b/example/assets/chat/pattern.png differ diff --git a/example/assets/chat/photo-post.jpg b/example/assets/chat/photo-post.jpg new file mode 100644 index 0000000..e8c6f55 Binary files /dev/null and b/example/assets/chat/photo-post.jpg differ diff --git a/example/assets/chat/photo-reel.jpg b/example/assets/chat/photo-reel.jpg new file mode 100644 index 0000000..69dcb9c Binary files /dev/null and b/example/assets/chat/photo-reel.jpg differ diff --git a/example/assets/chat/play.png b/example/assets/chat/play.png new file mode 100644 index 0000000..cb7853a Binary files /dev/null and b/example/assets/chat/play.png differ diff --git a/example/assets/chat/plus.png b/example/assets/chat/plus.png new file mode 100644 index 0000000..3a73c66 Binary files /dev/null and b/example/assets/chat/plus.png differ diff --git a/example/assets/chat/reply.png b/example/assets/chat/reply.png new file mode 100644 index 0000000..e6d3d02 Binary files /dev/null and b/example/assets/chat/reply.png differ diff --git a/example/assets/chat/send.png b/example/assets/chat/send.png new file mode 100644 index 0000000..ef5fa0f Binary files /dev/null and b/example/assets/chat/send.png differ diff --git a/example/assets/chat/trash.png b/example/assets/chat/trash.png new file mode 100644 index 0000000..eaf1fa0 Binary files /dev/null and b/example/assets/chat/trash.png differ diff --git a/example/assets/cracks/camera-fill.png b/example/assets/cracks/camera-fill.png deleted file mode 100644 index 824eb89..0000000 Binary files a/example/assets/cracks/camera-fill.png and /dev/null differ diff --git a/example/assets/cracks/flashlight-off-fill.png b/example/assets/cracks/flashlight-off-fill.png deleted file mode 100644 index 6da5922..0000000 Binary files a/example/assets/cracks/flashlight-off-fill.png and /dev/null differ diff --git a/example/assets/cracks/lock-fill.png b/example/assets/cracks/lock-fill.png deleted file mode 100644 index 59689c9..0000000 Binary files a/example/assets/cracks/lock-fill.png and /dev/null differ diff --git a/example/assets/cracks/message-fill.png b/example/assets/cracks/message-fill.png deleted file mode 100644 index 2430cc7..0000000 Binary files a/example/assets/cracks/message-fill.png and /dev/null differ diff --git a/example/assets/flashlight/camera.png b/example/assets/flashlight/camera.png deleted file mode 100644 index 7d0e7cd..0000000 Binary files a/example/assets/flashlight/camera.png and /dev/null differ diff --git a/example/assets/flashlight/flashlight-off.png b/example/assets/flashlight/flashlight-off.png deleted file mode 100644 index f274b46..0000000 Binary files a/example/assets/flashlight/flashlight-off.png and /dev/null differ diff --git a/example/assets/flashlight/torch-glyph.png b/example/assets/flashlight/torch-glyph.png deleted file mode 100644 index c061934..0000000 Binary files a/example/assets/flashlight/torch-glyph.png and /dev/null differ diff --git a/example/assets/foam.jpg b/example/assets/foam.jpg index a780d89..1b2b00f 100644 Binary files a/example/assets/foam.jpg and b/example/assets/foam.jpg differ diff --git a/example/assets/imessage/video.png b/example/assets/imessage/video.png deleted file mode 100644 index cdcf269..0000000 Binary files a/example/assets/imessage/video.png and /dev/null differ diff --git a/example/assets/instagram/back.png b/example/assets/instagram/back.png deleted file mode 100644 index f5d99ef..0000000 Binary files a/example/assets/instagram/back.png and /dev/null differ diff --git a/example/assets/instagram/bookmark.png b/example/assets/instagram/bookmark.png deleted file mode 100644 index 5969fa1..0000000 Binary files a/example/assets/instagram/bookmark.png and /dev/null differ diff --git a/example/assets/instagram/camera.png b/example/assets/instagram/camera.png deleted file mode 100644 index b013874..0000000 Binary files a/example/assets/instagram/camera.png and /dev/null differ diff --git a/example/assets/instagram/image.png b/example/assets/instagram/image.png deleted file mode 100644 index 83987df..0000000 Binary files a/example/assets/instagram/image.png and /dev/null differ diff --git a/example/assets/instagram/info.png b/example/assets/instagram/info.png deleted file mode 100644 index 5a10819..0000000 Binary files a/example/assets/instagram/info.png and /dev/null differ diff --git a/example/assets/instagram/mic.png b/example/assets/instagram/mic.png deleted file mode 100644 index 9b89ac6..0000000 Binary files a/example/assets/instagram/mic.png and /dev/null differ diff --git a/example/assets/instagram/phone.png b/example/assets/instagram/phone.png deleted file mode 100644 index 7d9ca11..0000000 Binary files a/example/assets/instagram/phone.png and /dev/null differ diff --git a/example/assets/instagram/play.png b/example/assets/instagram/play.png deleted file mode 100644 index 1af7987..0000000 Binary files a/example/assets/instagram/play.png and /dev/null differ diff --git a/example/assets/instagram/plus.png b/example/assets/instagram/plus.png deleted file mode 100644 index 01601b5..0000000 Binary files a/example/assets/instagram/plus.png and /dev/null differ diff --git a/example/assets/instagram/send.png b/example/assets/instagram/send.png deleted file mode 100644 index e8ed077..0000000 Binary files a/example/assets/instagram/send.png and /dev/null differ diff --git a/example/assets/instagram/share.png b/example/assets/instagram/share.png deleted file mode 100644 index a4d32e8..0000000 Binary files a/example/assets/instagram/share.png and /dev/null differ diff --git a/example/assets/instagram/sticker.png b/example/assets/instagram/sticker.png deleted file mode 100644 index a2be709..0000000 Binary files a/example/assets/instagram/sticker.png and /dev/null differ diff --git a/example/assets/instagram/trash.png b/example/assets/instagram/trash.png deleted file mode 100644 index fcdf2ee..0000000 Binary files a/example/assets/instagram/trash.png and /dev/null differ diff --git a/example/assets/instagram/video.png b/example/assets/instagram/video.png deleted file mode 100644 index afea376..0000000 Binary files a/example/assets/instagram/video.png and /dev/null differ diff --git a/example/assets/iphone-background.png b/example/assets/iphone-background.png deleted file mode 100644 index b0f6cbe..0000000 Binary files a/example/assets/iphone-background.png and /dev/null differ diff --git a/example/assets/namedrop/avatar.jpg b/example/assets/namedrop/avatar.jpg deleted file mode 100644 index 6e9d919..0000000 Binary files a/example/assets/namedrop/avatar.jpg and /dev/null differ diff --git a/example/assets/namedrop/home-atlas.jpg b/example/assets/namedrop/home-atlas.jpg deleted file mode 100644 index a887b8f..0000000 Binary files a/example/assets/namedrop/home-atlas.jpg and /dev/null differ diff --git a/example/assets/scenes/misty-path.jpg b/example/assets/scenes/misty-path.jpg new file mode 100644 index 0000000..3dfa79d Binary files /dev/null and b/example/assets/scenes/misty-path.jpg differ diff --git a/example/assets/telegram/avatar.jpg b/example/assets/telegram/avatar.jpg deleted file mode 100644 index 1b75837..0000000 Binary files a/example/assets/telegram/avatar.jpg and /dev/null differ diff --git a/example/assets/telegram/back.png b/example/assets/telegram/back.png deleted file mode 100644 index e0e05c3..0000000 Binary files a/example/assets/telegram/back.png and /dev/null differ diff --git a/example/assets/telegram/checks.png b/example/assets/telegram/checks.png deleted file mode 100644 index e24beae..0000000 Binary files a/example/assets/telegram/checks.png and /dev/null differ diff --git a/example/assets/telegram/chevron.png b/example/assets/telegram/chevron.png deleted file mode 100644 index b360798..0000000 Binary files a/example/assets/telegram/chevron.png and /dev/null differ diff --git a/example/assets/telegram/close.png b/example/assets/telegram/close.png deleted file mode 100644 index acf37fa..0000000 Binary files a/example/assets/telegram/close.png and /dev/null differ diff --git a/example/assets/telegram/copy.png b/example/assets/telegram/copy.png deleted file mode 100644 index fb0a5b1..0000000 Binary files a/example/assets/telegram/copy.png and /dev/null differ diff --git a/example/assets/telegram/delete.png b/example/assets/telegram/delete.png deleted file mode 100644 index f346151..0000000 Binary files a/example/assets/telegram/delete.png and /dev/null differ diff --git a/example/assets/telegram/edit.png b/example/assets/telegram/edit.png deleted file mode 100644 index 80f57d8..0000000 Binary files a/example/assets/telegram/edit.png and /dev/null differ diff --git a/example/assets/telegram/forward.png b/example/assets/telegram/forward.png deleted file mode 100644 index 7dd98df..0000000 Binary files a/example/assets/telegram/forward.png and /dev/null differ diff --git a/example/assets/telegram/mic.png b/example/assets/telegram/mic.png deleted file mode 100644 index 3487b05..0000000 Binary files a/example/assets/telegram/mic.png and /dev/null differ diff --git a/example/assets/telegram/paperclip.png b/example/assets/telegram/paperclip.png deleted file mode 100644 index fbfab27..0000000 Binary files a/example/assets/telegram/paperclip.png and /dev/null differ diff --git a/example/assets/telegram/pattern.png b/example/assets/telegram/pattern.png deleted file mode 100644 index c157d7d..0000000 Binary files a/example/assets/telegram/pattern.png and /dev/null differ diff --git a/example/assets/telegram/pin.png b/example/assets/telegram/pin.png deleted file mode 100644 index 4af88d2..0000000 Binary files a/example/assets/telegram/pin.png and /dev/null differ diff --git a/example/assets/telegram/reply.png b/example/assets/telegram/reply.png deleted file mode 100644 index 927e3ef..0000000 Binary files a/example/assets/telegram/reply.png and /dev/null differ diff --git a/example/assets/telegram/select.png b/example/assets/telegram/select.png deleted file mode 100644 index 714730f..0000000 Binary files a/example/assets/telegram/select.png and /dev/null differ diff --git a/example/assets/telegram/sticker.png b/example/assets/telegram/sticker.png deleted file mode 100644 index 9f1fa28..0000000 Binary files a/example/assets/telegram/sticker.png and /dev/null differ diff --git a/example/assets/telegram/viewed.png b/example/assets/telegram/viewed.png deleted file mode 100644 index c4f37a3..0000000 Binary files a/example/assets/telegram/viewed.png and /dev/null differ diff --git a/example/assets/tools/generate-assets.py b/example/assets/tools/generate-assets.py new file mode 100644 index 0000000..728489e --- /dev/null +++ b/example/assets/tools/generate-assets.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +"""Draws the app's original assets. Needs Pillow and numpy. + +Usage: generate-assets.py + + holds the CC0 sources listed in docs/credits.md: + cake.jpg "3rd birthday cake (Unsplash 10b8Lvvc-4g).jpg", 3840 px wide + post.jpg "Forest path at sunset (Unsplash).jpg" + reel.jpg "Misty path (Unsplash).jpg" +""" +import math +import random +import sys +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFilter + +ASSETS = Path(__file__).resolve().parent.parent +ICON = 96 +SS = 4 +UNIT = ICON * SS / 24 + + +def canvas(size=ICON * SS): + image = Image.new('RGBA', (size, size), (0, 0, 0, 0)) + return image, ImageDraw.Draw(image) + + +def stroke(draw, points, color='white', width=2.0): + scaled = [(x * UNIT, y * UNIT) for x, y in points] + w = width * UNIT + draw.line(scaled, fill=color, width=round(w), joint='curve') + for x, y in (scaled[0], scaled[-1]): + draw.ellipse((x - w / 2, y - w / 2, x + w / 2, y + w / 2), fill=color) + + +def arc(cx, cy, r, start, end, steps=48): + return [ + ( + cx + r * math.cos(math.radians(start + (end - start) * i / steps)), + cy + r * math.sin(math.radians(start + (end - start) * i / steps)), + ) + for i in range(steps + 1) + ] + + +def disc(draw, cx, cy, r, color): + draw.ellipse( + ((cx - r) * UNIT, (cy - r) * UNIT, (cx + r) * UNIT, (cy + r) * UNIT), + fill=color, + ) + + +def box(draw, x0, y0, x1, y1, radius, color): + draw.rounded_rectangle( + (x0 * UNIT, y0 * UNIT, x1 * UNIT, y1 * UNIT), + radius=radius * UNIT, + fill=color, + ) + + +def save_icon(image, path, size=ICON): + path.parent.mkdir(parents=True, exist_ok=True) + image.resize((size, size), Image.LANCZOS).save(path, optimize=True) + + +def chat_icons(): + def back(d): + stroke(d, [(15, 5), (8, 12), (15, 19)], width=2.4) + + def plus(d): + stroke(d, [(12, 5), (12, 19)], width=2.2) + stroke(d, [(5, 12), (19, 12)], width=2.2) + + def mic(d): + box(d, 8.5, 3, 15.5, 14.5, 3.5, 'white') + stroke(d, arc(12, 11, 7, 0, 180), width=1.9) + stroke(d, [(12, 18), (12, 21)], width=1.9) + + def reply(d): + stroke(d, [(10, 5), (4, 11), (10, 17)], width=2.1) + stroke(d, [(4, 11), (13, 11)] + arc(13, 17, 6, -90, 0), width=2.1) + + def copy(d): + box(d, 9, 9, 20, 20, 2.5, 'white') + box(d, 10.8, 10.8, 18.2, 18.2, 1.2, (0, 0, 0, 0)) + stroke(d, [(5, 15), (4, 14), (4, 5), (5, 4), (14, 4), (15, 5)], width=1.8) + + def trash(d): + stroke(d, [(4, 7), (20, 7)], width=2) + stroke(d, [(9.5, 7), (10, 4), (14, 4), (14.5, 7)], width=1.8) + stroke(d, [(6.5, 7), (7.5, 20), (16.5, 20), (17.5, 7)], width=2) + stroke(d, [(12, 11), (12, 16.5)], width=1.6) + + def send(d): + stroke(d, [(12, 20), (12, 5)], width=2.4) + stroke(d, [(6, 11), (12, 5), (18, 11)], width=2.4) + + def play(d): + d.polygon( + [(7 * UNIT, 4 * UNIT), (20 * UNIT, 12 * UNIT), (7 * UNIT, 20 * UNIT)], + fill='white', + ) + + for draw_icon in (back, plus, mic, reply, copy, trash, send, play): + image, draw = canvas() + draw_icon(draw) + save_icon(image, ASSETS / 'chat' / f'{draw_icon.__name__}.png') + + +def chat_pattern(): + image = Image.new('RGB', (736, 1139), (15, 22, 21)) + draw = ImageDraw.Draw(image) + for row, y in enumerate(range(14, 1139, 28)): + for x in range(14 + (row % 2) * 14, 736, 28): + draw.ellipse((x - 1.5, y - 1.5, x + 1.5, y + 1.5), fill=(31, 42, 40)) + image.save(ASSETS / 'chat' / 'pattern.png', optimize=True) + + +def avatar(name, background, foreground, shape): + image, draw = canvas(320) + draw.ellipse((0, 0, 319, 319), fill=background) + if shape == 'peaks': + draw.polygon([(40, 240), (130, 90), (195, 190), (225, 150), (285, 240)], fill=foreground) + elif shape == 'rings': + for r in (110, 72, 34): + draw.ellipse((160 - r, 160 - r, 160 + r, 160 + r), outline=foreground, width=18) + elif shape == 'bars': + for i, h in enumerate((70, 130, 190, 110)): + x = 72 + i * 48 + draw.rounded_rectangle((x, 160 - h / 2, x + 28, 160 + h / 2), 14, fill=foreground) + image.resize((160, 160), Image.LANCZOS).save(ASSETS / 'chat' / name, optimize=True) + + +def cover_crop(image, width, height, focus_y=0.5): + scale = max(width / image.width, height / image.height) + resized = image.resize( + (round(image.width * scale), round(image.height * scale)), Image.LANCZOS + ) + left = (resized.width - width) // 2 + top = round((resized.height - height) * focus_y) + return resized.crop((left, top, left + width, top + height)) + + +def chat_photos(commons): + post = Image.open(commons / 'post.jpg').convert('RGB') + cover_crop(post, 720, 612).save(ASSETS / 'chat' / 'photo-post.jpg', quality=84) + reel = Image.open(commons / 'reel.jpg').convert('RGB') + cover_crop(reel, 480, 870).save(ASSETS / 'chat' / 'photo-reel.jpg', quality=84) + + +CLOUD = (205, 214, 226, 255) +CLOUD_DARK = (150, 161, 178, 255) +SUN = (255, 196, 66, 255) +MOON = (232, 226, 200, 255) +WATER = (104, 190, 255, 255) +SNOW = (226, 240, 255, 255) +BOLT = (255, 214, 92, 255) + + +def cloud(d, color=CLOUD, dy=0.0): + box(d, 3, 11 + dy, 21, 18 + dy, 3.5, color) + disc(d, 9, 11 + dy, 4, color) + disc(d, 14.5, 9.5 + dy, 5, color) + + +def sun(d, cx=12, cy=12, r=4.6): + disc(d, cx, cy, r, SUN) + for i in range(8): + a = math.radians(i * 45) + stroke( + d, + [ + (cx + (r + 2) * math.cos(a), cy + (r + 2) * math.sin(a)), + (cx + (r + 4) * math.cos(a), cy + (r + 4) * math.sin(a)), + ], + color=SUN, + width=1.5, + ) + + +def moon(d, cx=12, cy=12, r=7): + layer, ld = canvas() + disc(ld, cx, cy, r, MOON) + disc(ld, cx + r * 0.55, cy - r * 0.4, r * 0.85, (0, 0, 0, 0)) + d._image.alpha_composite(layer) + + +def drops(d, xs, color=WATER): + for x in xs: + stroke(d, [(x + 0.8, 18.5), (x - 0.8, 21.5)], color=color, width=1.5) + + +def flakes(d, xs): + for x in xs: + disc(d, x, 20.5, 1.1, SNOW) + + +def lines(d, rows, color=CLOUD): + for y, x0, x1 in rows: + stroke(d, [(x0, y), (x1, y)], color=color, width=1.8) + + +def horizon_sun(d, arrow_up): + sun_y = 15 + disc(d, 12, sun_y, 4.5, SUN) + box(d, 0, sun_y, 24, 24, 0, (0, 0, 0, 0)) + lines(d, [(16.5, 3, 21)], color=SUN) + tip, tail = (3, 8) if arrow_up else (8, 3) + stroke(d, [(12, tail), (12, tip)], color=SNOW, width=1.6) + stroke(d, [(9.5, tip + (2.5 if arrow_up else -2.5)), (12, tip), (14.5, tip + (2.5 if arrow_up else -2.5))], color=SNOW, width=1.6) + + +def weather_icons(): + def shifted_moon(d): + moon(d, 16, 8, 6) + + def clear_day(d): + sun(d) + + def partly_cloudy_day(d): + sun(d, 16, 8, 3.8) + cloud(d, dy=1.5) + + def cloudy(d): + cloud(d, CLOUD_DARK, dy=-3) + cloud(d, dy=1.5) + + def haze(d): + sun(d, 12, 8, 3.4) + lines(d, [(15, 4, 20), (18.5, 7, 17)]) + + def fog(d): + lines(d, [(8, 5, 19), (12, 3, 16), (16, 8, 21), (20, 5, 15)]) + + def windy(d): + stroke(d, [(3, 9), (15, 9)] + arc(15, 6, 3, 90, -120), color=CLOUD) + stroke(d, [(3, 13), (19, 13)] + arc(19, 16, 3, -90, 120), color=CLOUD) + stroke(d, [(3, 17), (10, 17)], color=CLOUD) + + def drizzle(d): + cloud(d, dy=-2) + drops(d, (9, 15)) + + def rain(d): + cloud(d, dy=-2) + drops(d, (7.5, 12, 16.5)) + + def heavy_rain(d): + cloud(d, CLOUD_DARK, dy=-2) + drops(d, (5.5, 9.5, 13.5, 17.5)) + + def thunderstorm(d): + cloud(d, CLOUD_DARK, dy=-3) + d.polygon( + [(c * UNIT, r * UNIT) for c, r in ((13.5, 13), (9, 19), (12, 19), (10.5, 23.5), (16, 17), (13, 17))], + fill=BOLT, + ) + + def snow(d): + cloud(d, dy=-2) + flakes(d, (8, 12, 16)) + + def heavy_snow(d): + cloud(d, CLOUD_DARK, dy=-2) + flakes(d, (6, 10, 14, 18)) + disc(d, 8, 22.8, 1, SNOW) + disc(d, 16, 22.8, 1, SNOW) + + def sleet(d): + cloud(d, dy=-2) + drops(d, (8, 16)) + flakes(d, (12,)) + + def clear_night(d): + moon(d, 11, 12, 8) + disc(d, 19, 6, 0.9, SNOW) + disc(d, 20, 13, 0.7, SNOW) + + def partly_cloudy_night(d): + shifted_moon(d) + cloud(d, dy=1.5) + + def drizzle_night(d): + shifted_moon(d) + cloud(d, dy=-1) + drops(d, (9, 15)) + + def sunrise(d): + horizon_sun(d, True) + + def sunset(d): + horizon_sun(d, False) + + for draw_icon in ( + clear_day, partly_cloudy_day, cloudy, haze, fog, windy, drizzle, rain, + heavy_rain, thunderstorm, snow, heavy_snow, sleet, clear_night, + partly_cloudy_night, drizzle_night, sunrise, sunset, + ): + image, draw = canvas() + draw._image = image + draw_icon(draw) + name = draw_icon.__name__.replace('_', '-') + save_icon(image, ASSETS / 'weather' / f'{name}.png') + + image, draw = canvas() + stroke(draw, arc(12, 12, 9, 0, 360), color=(255, 255, 255, 170), width=2) + stroke(draw, [(12, 7), (12, 12), (16, 14)], color=(255, 255, 255, 170), width=2) + save_icon(image, ASSETS / 'weather' / 'clock.png') + + image, draw = canvas() + box(draw, 3, 5, 21, 21, 3, (255, 255, 255, 170)) + box(draw, 5, 10, 19, 19, 1.5, (0, 0, 0, 0)) + stroke(draw, [(8, 3), (8, 6)], color=(255, 255, 255, 170), width=2) + stroke(draw, [(16, 3), (16, 6)], color=(255, 255, 255, 170), width=2) + save_icon(image, ASSETS / 'weather' / 'calendar.png') + + +def foam(): + random.seed(7) + size = 1536 + seam = round(size * 0.615) + image = Image.new('RGB', (size, size), (250, 244, 228)) + draw = ImageDraw.Draw(image) + + for y in range(seam, size): + k = (y - seam) / (size - seam) + draw.line((0, y, size, y), fill=(int(238 - 14 * k), int(170 - 22 * k), int(24 + 4 * k))) + + def foam_bubble(x, y, r): + draw.ellipse((x - r, y - r, x + r, y + r), fill=(236, 226, 200)) + draw.ellipse((x - r, y - r, x + r, y + r), outline=(255, 252, 244), width=max(2, round(r * 0.14))) + + for _ in range(1500): + foam_bubble(random.uniform(0, size), random.uniform(0, seam - 20), random.uniform(3, 12)) + for _ in range(130): + foam_bubble(random.uniform(0, size), random.uniform(0, seam - 60), random.uniform(14, 44)) + for _ in range(420): + x = random.uniform(0, size) + y = seam + random.gauss(-26, 22) + draw.ellipse((x - 9, y - 9, x + 9, y + 9), fill=(255, 250, 236)) + foam_bubble(x, y, random.uniform(3, 15)) + + for _ in range(620): + x, y, r = random.uniform(0, size), random.uniform(seam + 30, size), random.uniform(3, 20) + draw.ellipse((x - r, y - r, x + r, y + r), fill=(246, 188, 42), outline=(190, 112, 12), width=max(2, round(r * 0.18))) + h = r * 0.34 + draw.ellipse((x - h, y - r * 0.6, x + h, y - r * 0.6 + 2 * h), fill=(255, 244, 196)) + + result = image.filter(ImageFilter.GaussianBlur(1.6)).resize((768, 768), Image.LANCZOS) + pixels = np.asarray(result, dtype=np.float32) + grain = np.random.default_rng(7).normal(0, 2.2, pixels.shape[:2])[..., None] + Image.fromarray(np.clip(pixels + grain, 0, 255).astype(np.uint8)).save(ASSETS / 'foam.jpg', quality=88) + + +def wax_top(x): + points = [ + (1100, 840), (1180, 835), (1230, 850), (1270, 880), (1305, 898), + (1340, 902), (1375, 875), (1410, 858), (1460, 862), (1600, 880), + ] + xs, ys = zip(*points) + return np.interp(x, xs, ys) + + +def cake(commons): + source = np.asarray(Image.open(commons / 'cake.jpg').convert('RGB'), dtype=np.float32) + height, width, _ = source.shape + background = source[60:140].mean(axis=(0, 1)) + yy, xx = np.mgrid[0:height, 0:width] + in_glow = ((xx - 1300) / 240.0) ** 2 + ((yy - 700) / 340.0) ** 2 < 1 + above_wax = yy < wax_top(xx) - 3 + mask = Image.fromarray(((in_glow & above_wax) * 255).astype(np.uint8)) + mask = np.asarray(mask.filter(ImageFilter.GaussianBlur(4)), dtype=np.float32)[..., None] / 255 + noise = np.random.default_rng(3).normal(0, 1.1, source.shape[:2])[..., None] + source = source * (1 - mask) + (background + noise) * mask + + crop_left, crop_width = 130, 2350 + pad = round(crop_width * 1043 / 730) - height + top = background + np.random.default_rng(4).normal(0, 1.1, (pad, crop_width, 1)) + full = np.concatenate([top, source[:, crop_left:crop_left + crop_width]], axis=0) + image = Image.fromarray(np.clip(full, 0, 255).astype(np.uint8)) + image.resize((730, 1043), Image.LANCZOS).save(ASSETS / 'candle' / 'cake.jpg', quality=88) + + +def wave_scenes(commons): + width, height = 1206, 2622 + photo = cover_crop(Image.open(commons / 'reel.jpg').convert('RGB'), width, height, 0.5) + shade = Image.new('RGB', (width, height), (10, 26, 34)) + scene = Image.blend(photo, shade, 0.34) + (ASSETS / 'scenes').mkdir(exist_ok=True) + scene.save(ASSETS / 'scenes' / 'misty-path.jpg', quality=86) + + island = ImageDraw.Draw(atlas_panel := scene.copy()) + pill_w, pill_h = 0.312 * width, 0.0408 * height + island.rounded_rectangle( + (width / 2 - pill_w / 2, 0.0376 * height - pill_h / 2, width / 2 + pill_w / 2, 0.0376 * height + pill_h / 2), + pill_h / 2, + fill=(0, 0, 0), + ) + atlas = Image.new('RGB', (width * 3, height)) + atlas.paste(atlas_panel, (0, 0)) + atlas.paste(atlas_panel.filter(ImageFilter.GaussianBlur(22)), (width, 0)) + atlas.paste(atlas_panel.filter(ImageFilter.GaussianBlur(60)), (width * 2, 0)) + atlas.resize((2412, 1748), Image.LANCZOS).save(ASSETS / 'wave' / 'scene-atlas.jpg', quality=84) + + swing = Image.open(ASSETS / 'cracks' / 'swing.jpg').convert('RGB') + dusk = swing.crop((820, 0, 1498, 1473)).resize((width, height), Image.LANCZOS) + dusk.save(ASSETS / 'wave' / 'dusk.jpg', quality=86) + + +def main(): + commons = Path(sys.argv[1]) + chat_icons() + chat_pattern() + avatar('avatar-peaks.png', (34, 92, 84), (222, 240, 226), 'peaks') + avatar('avatar-rings.png', (64, 58, 110), (236, 230, 250), 'rings') + avatar('avatar-bars.png', (150, 104, 32), (255, 240, 212), 'bars') + chat_photos(commons) + weather_icons() + foam() + cake(commons) + wave_scenes(commons) + + +if __name__ == '__main__': + main() diff --git a/example/assets/wave/dusk.jpg b/example/assets/wave/dusk.jpg new file mode 100644 index 0000000..e12269e Binary files /dev/null and b/example/assets/wave/dusk.jpg differ diff --git a/example/assets/wave/scene-atlas.jpg b/example/assets/wave/scene-atlas.jpg new file mode 100644 index 0000000..cb8ce8b Binary files /dev/null and b/example/assets/wave/scene-atlas.jpg differ diff --git a/example/assets/weather/calendar.png b/example/assets/weather/calendar.png index f51b1d9..c614a1d 100644 Binary files a/example/assets/weather/calendar.png and b/example/assets/weather/calendar.png differ diff --git a/example/assets/weather/clear-day.png b/example/assets/weather/clear-day.png index 43fe86b..c92cbd3 100644 Binary files a/example/assets/weather/clear-day.png and b/example/assets/weather/clear-day.png differ diff --git a/example/assets/weather/clear-night.png b/example/assets/weather/clear-night.png index 1f8894d..2f08190 100644 Binary files a/example/assets/weather/clear-night.png and b/example/assets/weather/clear-night.png differ diff --git a/example/assets/weather/clock.png b/example/assets/weather/clock.png index 5461008..2c3ac50 100644 Binary files a/example/assets/weather/clock.png and b/example/assets/weather/clock.png differ diff --git a/example/assets/weather/cloudy.png b/example/assets/weather/cloudy.png index 3e14e9e..24c565c 100644 Binary files a/example/assets/weather/cloudy.png and b/example/assets/weather/cloudy.png differ diff --git a/example/assets/weather/drizzle-night.png b/example/assets/weather/drizzle-night.png index 79d7294..d575a6c 100644 Binary files a/example/assets/weather/drizzle-night.png and b/example/assets/weather/drizzle-night.png differ diff --git a/example/assets/weather/drizzle.png b/example/assets/weather/drizzle.png index 0d09492..e448a42 100644 Binary files a/example/assets/weather/drizzle.png and b/example/assets/weather/drizzle.png differ diff --git a/example/assets/weather/fog.png b/example/assets/weather/fog.png index 30f2e7d..1c725ec 100644 Binary files a/example/assets/weather/fog.png and b/example/assets/weather/fog.png differ diff --git a/example/assets/weather/haze.png b/example/assets/weather/haze.png index 85d709c..61faa39 100644 Binary files a/example/assets/weather/haze.png and b/example/assets/weather/haze.png differ diff --git a/example/assets/weather/heavy-rain.png b/example/assets/weather/heavy-rain.png index 665806d..1b7d211 100644 Binary files a/example/assets/weather/heavy-rain.png and b/example/assets/weather/heavy-rain.png differ diff --git a/example/assets/weather/heavy-snow.png b/example/assets/weather/heavy-snow.png index b9335a7..3f394e2 100644 Binary files a/example/assets/weather/heavy-snow.png and b/example/assets/weather/heavy-snow.png differ diff --git a/example/assets/weather/partly-cloudy-day.png b/example/assets/weather/partly-cloudy-day.png index 360d58f..6a88fea 100644 Binary files a/example/assets/weather/partly-cloudy-day.png and b/example/assets/weather/partly-cloudy-day.png differ diff --git a/example/assets/weather/partly-cloudy-night.png b/example/assets/weather/partly-cloudy-night.png index 2a8ece0..6d02f71 100644 Binary files a/example/assets/weather/partly-cloudy-night.png and b/example/assets/weather/partly-cloudy-night.png differ diff --git a/example/assets/weather/rain.png b/example/assets/weather/rain.png index 562f80d..1e012ff 100644 Binary files a/example/assets/weather/rain.png and b/example/assets/weather/rain.png differ diff --git a/example/assets/weather/sleet.png b/example/assets/weather/sleet.png index 7697260..6aad4bb 100644 Binary files a/example/assets/weather/sleet.png and b/example/assets/weather/sleet.png differ diff --git a/example/assets/weather/snow.png b/example/assets/weather/snow.png index ed4730c..13af37a 100644 Binary files a/example/assets/weather/snow.png and b/example/assets/weather/snow.png differ diff --git a/example/assets/weather/sunrise.png b/example/assets/weather/sunrise.png index 87b5adc..01140a5 100644 Binary files a/example/assets/weather/sunrise.png and b/example/assets/weather/sunrise.png differ diff --git a/example/assets/weather/sunset.png b/example/assets/weather/sunset.png index 2892be0..b0f9307 100644 Binary files a/example/assets/weather/sunset.png and b/example/assets/weather/sunset.png differ diff --git a/example/assets/weather/thunderstorm.png b/example/assets/weather/thunderstorm.png index a6936c9..d8c1a62 100644 Binary files a/example/assets/weather/thunderstorm.png and b/example/assets/weather/thunderstorm.png differ diff --git a/example/assets/weather/windy.png b/example/assets/weather/windy.png index 06b78bf..561d46b 100644 Binary files a/example/assets/weather/windy.png and b/example/assets/weather/windy.png differ diff --git a/example/fastlane/Appfile b/example/fastlane/Appfile new file mode 100644 index 0000000..551958c --- /dev/null +++ b/example/fastlane/Appfile @@ -0,0 +1,3 @@ +app_identifier("com.blazejkustra.rneffects") +apple_id(ENV["APPLE_ID"]) if ENV["APPLE_ID"] +team_id(ENV["APPLE_TEAM_ID"]) if ENV["APPLE_TEAM_ID"] diff --git a/example/fastlane/Fastfile b/example/fastlane/Fastfile new file mode 100644 index 0000000..d83b6df --- /dev/null +++ b/example/fastlane/Fastfile @@ -0,0 +1,66 @@ +default_platform(:ios) + +APP_ID = "com.blazejkustra.rneffects" +WORKSPACE = "ios/Effects.xcworkspace" + +def next_build_number + latest_testflight_build_number(app_identifier: APP_ID, initial_build_number: 0) + 1 +end + +def api_key_auth_args + path = ENV["APP_STORE_CONNECT_API_KEY_PATH"] + return [] unless path + key = JSON.parse(File.read(File.expand_path(path))) + return [] unless key["key_filepath"] + [ + "-authenticationKeyPath #{File.expand_path(key['key_filepath']).shellescape}", + "-authenticationKeyID #{key['key_id']}", + "-authenticationKeyIssuerID #{key['issuer_id']}" + ] +end + +def signing_xcargs(build_number) + args = ["-allowProvisioningUpdates", "CURRENT_PROJECT_VERSION=#{build_number}"] + api_key_auth_args + args << "DEVELOPMENT_TEAM=#{ENV['APPLE_TEAM_ID']}" if ENV["APPLE_TEAM_ID"] + args.join(" ") +end + +platform :ios do + private_lane :build do + Dir.chdir("..") do + sh("yarn", "prebuild:clean", "--platform", "ios") + end + build_number = next_build_number + UI.message("Building #{APP_ID} build #{build_number}") + build_app( + workspace: WORKSPACE, + scheme: "Effects", + configuration: "Release", + export_method: "app-store", + xcargs: signing_xcargs(build_number), + output_directory: "build/ios", + clean: true + ) + end + + desc "Build and upload to TestFlight" + lane :beta do + build + upload_to_testflight(skip_waiting_for_build_processing: true) + end + + desc "Upload the listing and screenshots without a binary" + lane :listing do + upload_to_app_store( + app_version: "1.0.0", + metadata_path: "fastlane/metadata", + screenshots_path: "fastlane/screenshots", + skip_binary_upload: true, + overwrite_screenshots: true, + force: true, + submit_for_review: false, + automatic_release: false, + precheck_include_in_app_purchases: false + ) + end +end diff --git a/example/fastlane/metadata/copyright.txt b/example/fastlane/metadata/copyright.txt new file mode 100644 index 0000000..bdf6335 --- /dev/null +++ b/example/fastlane/metadata/copyright.txt @@ -0,0 +1 @@ +2026 Blazej Kustra diff --git a/example/fastlane/metadata/en-US/description.txt b/example/fastlane/metadata/en-US/description.txt new file mode 100644 index 0000000..21be57c --- /dev/null +++ b/example/fastlane/metadata/en-US/description.txt @@ -0,0 +1,16 @@ +Effects is a pocket collection of interactive visual effects. Every one renders live on your iPhone's GPU, and many respond to you: touch, tilt, sound and motion. + +Things to try: + +- Tap 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. + +Sensor and microphone readings are used live for the animation and stay on your iPhone. The cracks, chats and cards are visual effects only: nothing happens to your device, and no messages or payments are sent. diff --git a/example/fastlane/metadata/en-US/keywords.txt b/example/fastlane/metadata/en-US/keywords.txt new file mode 100644 index 0000000..88dc625 --- /dev/null +++ b/example/fastlane/metadata/en-US/keywords.txt @@ -0,0 +1 @@ +interactive,physics,particles,satisfying,fidget,toy,art,animation,visual,sensory,relaxing,simulation diff --git a/example/fastlane/metadata/en-US/marketing_url.txt b/example/fastlane/metadata/en-US/marketing_url.txt new file mode 100644 index 0000000..fa40cbe --- /dev/null +++ b/example/fastlane/metadata/en-US/marketing_url.txt @@ -0,0 +1 @@ +https://github.com/blazejkustra/react-native-effects diff --git a/example/fastlane/metadata/en-US/name.txt b/example/fastlane/metadata/en-US/name.txt new file mode 100644 index 0000000..049442a --- /dev/null +++ b/example/fastlane/metadata/en-US/name.txt @@ -0,0 +1 @@ +Effects: shaders & fun diff --git a/example/fastlane/metadata/en-US/privacy_url.txt b/example/fastlane/metadata/en-US/privacy_url.txt new file mode 100644 index 0000000..865b46b --- /dev/null +++ b/example/fastlane/metadata/en-US/privacy_url.txt @@ -0,0 +1 @@ +https://github.com/blazejkustra/react-native-effects/blob/main/docs/privacy-policy.md diff --git a/example/fastlane/metadata/en-US/promotional_text.txt b/example/fastlane/metadata/en-US/promotional_text.txt new file mode 100644 index 0000000..434a3e7 --- /dev/null +++ b/example/fastlane/metadata/en-US/promotional_text.txt @@ -0,0 +1 @@ +Dozens of live GPU effects you can touch. Crack the glass, blow out a candle, pop bubble wrap, stir a lava lamp. Every frame renders in real time on your iPhone. diff --git a/example/fastlane/metadata/en-US/subtitle.txt b/example/fastlane/metadata/en-US/subtitle.txt new file mode 100644 index 0000000..808cd7d --- /dev/null +++ b/example/fastlane/metadata/en-US/subtitle.txt @@ -0,0 +1 @@ +Toys you tilt, blow & shake diff --git a/example/fastlane/metadata/en-US/support_url.txt b/example/fastlane/metadata/en-US/support_url.txt new file mode 100644 index 0000000..fb42cab --- /dev/null +++ b/example/fastlane/metadata/en-US/support_url.txt @@ -0,0 +1 @@ +https://github.com/blazejkustra/react-native-effects/issues diff --git a/example/fastlane/metadata/primary_category.txt b/example/fastlane/metadata/primary_category.txt new file mode 100644 index 0000000..edbeebe --- /dev/null +++ b/example/fastlane/metadata/primary_category.txt @@ -0,0 +1 @@ +ENTERTAINMENT diff --git a/example/fastlane/screenshots/en-US/01-catalog.png b/example/fastlane/screenshots/en-US/01-catalog.png new file mode 100644 index 0000000..beb2d0e Binary files /dev/null and b/example/fastlane/screenshots/en-US/01-catalog.png differ diff --git a/example/fastlane/screenshots/en-US/02-lava-lamp.png b/example/fastlane/screenshots/en-US/02-lava-lamp.png new file mode 100644 index 0000000..63f985d Binary files /dev/null and b/example/fastlane/screenshots/en-US/02-lava-lamp.png differ diff --git a/example/fastlane/screenshots/en-US/03-cracked-screen.png b/example/fastlane/screenshots/en-US/03-cracked-screen.png new file mode 100644 index 0000000..d2b9914 Binary files /dev/null and b/example/fastlane/screenshots/en-US/03-cracked-screen.png differ diff --git a/example/fastlane/screenshots/en-US/04-bubble-wrap.png b/example/fastlane/screenshots/en-US/04-bubble-wrap.png new file mode 100644 index 0000000..42319df Binary files /dev/null and b/example/fastlane/screenshots/en-US/04-bubble-wrap.png differ diff --git a/example/fastlane/screenshots/en-US/05-dandelion.png b/example/fastlane/screenshots/en-US/05-dandelion.png new file mode 100644 index 0000000..3b43ced Binary files /dev/null and b/example/fastlane/screenshots/en-US/05-dandelion.png differ diff --git a/example/fastlane/screenshots/en-US/06-hourglass.png b/example/fastlane/screenshots/en-US/06-hourglass.png new file mode 100644 index 0000000..5e3648b Binary files /dev/null and b/example/fastlane/screenshots/en-US/06-hourglass.png differ diff --git a/example/fastlane/screenshots/en-US/07-ferrofluid.png b/example/fastlane/screenshots/en-US/07-ferrofluid.png new file mode 100644 index 0000000..2b601de Binary files /dev/null and b/example/fastlane/screenshots/en-US/07-ferrofluid.png differ diff --git a/example/goldie/flows/store-01-catalog.yaml b/example/goldie/flows/store-01-catalog.yaml new file mode 100644 index 0000000..a759a28 --- /dev/null +++ b/example/goldie/flows/store-01-catalog.yaml @@ -0,0 +1,9 @@ +steps: + - launch: com.blazejkustra.rneffects + - await: { visible: { text: Search effects } } + - tool: gesture-swipe + args: { fromX: 0.5, fromY: 0.85, toX: 0.5, toY: 0.1, durationMs: 700, momentum: false } + - tool: gesture-swipe + args: { fromX: 0.5, fromY: 0.8, toX: 0.5, toY: 0.2, durationMs: 700, momentum: false } + - wait: 1500 + - await: { idle: true } diff --git a/example/goldie/flows/store-02-lava-lamp.yaml b/example/goldie/flows/store-02-lava-lamp.yaml new file mode 100644 index 0000000..660f037 --- /dev/null +++ b/example/goldie/flows/store-02-lava-lamp.yaml @@ -0,0 +1,10 @@ +steps: + - launch: com.blazejkustra.rneffects + - await: { visible: { text: Search effects } } + - tap: { text: Search effects } + - wait: 1000 + - tool: keyboard + args: { text: "lava" } + - wait: 500 + - tap: { text: "Lava Lamp" } + - wait: 9000 diff --git a/example/goldie/flows/store-03-cracked-screen.yaml b/example/goldie/flows/store-03-cracked-screen.yaml new file mode 100644 index 0000000..b2b4c14 --- /dev/null +++ b/example/goldie/flows/store-03-cracked-screen.yaml @@ -0,0 +1,19 @@ +steps: + - launch: com.blazejkustra.rneffects + - await: { visible: { text: Search effects } } + - tap: { text: Search effects } + - wait: 1000 + - tool: keyboard + args: { text: "cracked" } + - wait: 500 + - tap: { text: "Cracked Screen" } + - wait: 2000 + - tool: gesture-tap + args: { x: 0.3, y: 0.3 } + - wait: 600 + - tool: gesture-tap + args: { x: 0.7, y: 0.5 } + - wait: 600 + - tool: gesture-tap + args: { x: 0.3, y: 0.62 } + - wait: 1500 diff --git a/example/goldie/flows/store-04-bubble-wrap.yaml b/example/goldie/flows/store-04-bubble-wrap.yaml new file mode 100644 index 0000000..53d2469 --- /dev/null +++ b/example/goldie/flows/store-04-bubble-wrap.yaml @@ -0,0 +1,20 @@ +steps: + - launch: com.blazejkustra.rneffects + - await: { visible: { text: Search effects } } + - tap: { text: Search effects } + - wait: 1000 + - tool: keyboard + args: { text: "bubble" } + - wait: 500 + - tap: { text: "Bubble Wrap" } + - wait: 2000 + - echo: Near-vertical drags, because a mostly sideways drag triggers the swipe-back gesture + - tool: gesture-swipe + args: { fromX: 0.3, fromY: 0.2, toX: 0.3, toY: 0.72, durationMs: 700, momentum: false } + - tool: gesture-swipe + args: { fromX: 0.42, fromY: 0.2, toX: 0.42, toY: 0.72, durationMs: 700, momentum: false } + - tool: gesture-swipe + args: { fromX: 0.54, fromY: 0.2, toX: 0.54, toY: 0.72, durationMs: 700, momentum: false } + - tool: gesture-swipe + args: { fromX: 0.66, fromY: 0.2, toX: 0.66, toY: 0.72, durationMs: 700, momentum: false } + - wait: 1500 diff --git a/example/goldie/flows/store-05-dandelion.yaml b/example/goldie/flows/store-05-dandelion.yaml new file mode 100644 index 0000000..e15f237 --- /dev/null +++ b/example/goldie/flows/store-05-dandelion.yaml @@ -0,0 +1,14 @@ +steps: + - launch: com.blazejkustra.rneffects + - await: { visible: { text: Search effects } } + - tap: { text: Search effects } + - wait: 1000 + - tool: keyboard + args: { text: "dandelion" } + - wait: 500 + - tap: { text: "Dandelion" } + - wait: 2000 + - echo: Allow on the microphone prompt when it shows. Without a prompt this tap lands on the intact head and does nothing + - tool: gesture-tap + args: { x: 0.668, y: 0.652 } + - wait: 2000 diff --git a/example/goldie/flows/store-06-hourglass.yaml b/example/goldie/flows/store-06-hourglass.yaml new file mode 100644 index 0000000..8a71f7a --- /dev/null +++ b/example/goldie/flows/store-06-hourglass.yaml @@ -0,0 +1,13 @@ +steps: + - launch: com.blazejkustra.rneffects + - await: { visible: { text: Search effects } } + - tap: { text: Search effects } + - wait: 1000 + - tool: keyboard + args: { text: "hourglass" } + - wait: 500 + - tap: { text: "Hourglass" } + - wait: 1000 + - tap: { text: 5 minutes } + - echo: Let about a quarter of the sand fall + - wait: 75000 diff --git a/example/goldie/flows/store-07-ferrofluid.yaml b/example/goldie/flows/store-07-ferrofluid.yaml new file mode 100644 index 0000000..54020dc --- /dev/null +++ b/example/goldie/flows/store-07-ferrofluid.yaml @@ -0,0 +1,14 @@ +steps: + - launch: com.blazejkustra.rneffects + - await: { visible: { text: Search effects } } + - tap: { text: Search effects } + - wait: 1000 + - tool: keyboard + args: { text: "ferro" } + - wait: 500 + - tap: { text: "Ferrofluid" } + - wait: 2000 + - echo: A held touch is the magnet, so the flow ends with the finger still down for the capture + - tool: gesture-custom + args: { events: [{ type: Down, x: 0.5, y: 0.45 }] } + - wait: 4000 diff --git a/example/goldie/flows/store-preview.yaml b/example/goldie/flows/store-preview.yaml new file mode 100644 index 0000000..77707a8 --- /dev/null +++ b/example/goldie/flows/store-preview.yaml @@ -0,0 +1,68 @@ +executionPrerequisite: App freshly restarted by goldie, settled on the catalog home. +steps: + - echo: Lift the finger the ferrofluid screenshot left down + - tool: gesture-custom + args: { events: [{ type: Up, x: 0.5, y: 0.45 }] } + - await: { visible: { text: Search effects } } + - tap: { text: Search effects } + - wait: 1000 + - tool: keyboard + args: { text: "cracked" } + - wait: 300 + - tap: { text: "Cracked Screen" } + - wait: 700 + - tool: gesture-tap + args: { x: 0.3, y: 0.3 } + - wait: 500 + - tool: gesture-tap + args: { x: 0.7, y: 0.5 } + - wait: 500 + - tool: gesture-tap + args: { x: 0.3, y: 0.62 } + - wait: 800 + - echo: Left edge swipe back to the search results + - tool: gesture-swipe + args: { fromX: 0.01, fromY: 0.5, toX: 0.95, toY: 0.5, durationMs: 300 } + - wait: 400 + - tap: { text: Clear text } + - tool: keyboard + args: { text: "ferro" } + - wait: 300 + - tap: { text: "Ferrofluid" } + - wait: 900 + - tool: gesture-custom + args: + interpolate: 30 + events: + - { type: Down, x: 0.5, y: 0.45 } + - { type: Move, x: 0.5, y: 0.45, delayMs: 1500 } + - { type: Move, x: 0.62, y: 0.55, delayMs: 700 } + - { type: Up, x: 0.62, y: 0.55, delayMs: 300 } + - wait: 300 + - echo: Left edge swipe back to the search results + - tool: gesture-swipe + args: { fromX: 0.01, fromY: 0.5, toX: 0.95, toY: 0.5, durationMs: 300 } + - wait: 400 + - tap: { text: Clear text } + - tool: keyboard + args: { text: "bloom" } + - wait: 300 + - tap: { text: "Ink Bloom" } + - wait: 800 + - tool: gesture-tap + args: { x: 0.35, y: 0.35 } + - wait: 1000 + - tool: gesture-tap + args: { x: 0.65, y: 0.6 } + - wait: 1500 + - echo: Left edge swipe back to the search results + - tool: gesture-swipe + args: { fromX: 0.01, fromY: 0.5, toX: 0.95, toY: 0.5, durationMs: 300 } + - wait: 400 + - tap: { text: Clear text } + - tool: keyboard + args: { text: "lava" } + - wait: 300 + - tap: { text: "Lava Lamp" } + - echo: End on a screen that keeps animating, because the recording drops a static tail + - wait: 2500 diff --git a/example/goldie/goldie.config.ts b/example/goldie/goldie.config.ts new file mode 100644 index 0000000..d424651 --- /dev/null +++ b/example/goldie/goldie.config.ts @@ -0,0 +1,99 @@ +const screenshot = ( + id: string, + flow: string, + headline: string, + subhead: string, + layout = 'hero' +) => ({ + kind: 'screenshot', + id, + flow, + layout, + headline: { 'en-US': headline }, + subhead: { 'en-US': subhead }, +}); + +export default { + appRoot: '..', + appPath: + process.env.EFFECTS_APP_PATH ?? + '/tmp/rnfx-dd/Build/Products/Release-iphonesimulator/Effects.app', + bundleId: 'com.blazejkustra.rneffects', + flowsDir: 'flows', + devices: ['iphone-6.9'], + locales: ['en-US'], + appearance: 'dark', + frame: { variant: '17-pro-silver' }, + theme: { + background: + 'linear-gradient(170deg, #1a1a1c 0%, #0b0b0c 55%, #000000 100%)', + headlineColor: '#f5f5f3', + subheadColor: '#a3a3a0', + fontFamily: 'Merriweather', + layout: 'hero', + copyHeightRatio: 0.2, + deviceWidthRatio: 0.82, + }, + store: { + name: 'Effects', + subtitle: { 'en-US': 'Screens you can touch' }, + developer: 'Blazej Kustra', + category: 'Entertainment', + ageRating: '13+', + description: { + 'en-US': + 'A collection of real-time effects that respond to touch, tilt, breath and voice.', + }, + }, + scenes: [ + screenshot( + 'catalog', + 'store-01-catalog', + 'Tilt it. Blow on it. Break it.', + 'Effects that answer your touch.' + ), + screenshot( + 'lava-lamp', + 'store-02-lava-lamp', + 'A pocket lava lamp', + 'Warm wax rises and sinks. Tilt to stir it.' + ), + screenshot( + 'cracked-screen', + 'store-03-cracked-screen', + 'Crack the glass', + 'Tap and it breaks where you hit it.', + 'classic' + ), + screenshot( + 'bubble-wrap', + 'store-04-bubble-wrap', + 'Pop every bubble', + 'Drag a finger and hear each one snap.' + ), + screenshot( + 'dandelion', + 'store-05-dandelion', + 'Blow the seeds away', + 'Breathe on your phone and watch them drift.' + ), + screenshot( + 'hourglass', + 'store-06-hourglass', + 'Time it with sand', + 'Pick a length, tap to pause, flip to restart.', + 'classic' + ), + screenshot( + 'ferrofluid', + 'store-07-ferrofluid', + 'Touch to raise spikes', + 'Hold a finger down and the fluid stands up.' + ), + { + kind: 'preview', + id: 'preview', + segments: [{ id: 'tour', flow: 'store-preview' }], + }, + ], +}; diff --git a/example/package.json b/example/package.json index 4ba7820..b840378 100644 --- a/example/package.json +++ b/example/package.json @@ -8,7 +8,9 @@ "android": "expo run:android", "ios": "expo run:ios", "prebuild": "expo prebuild", - "prebuild:clean": "expo prebuild --clean" + "prebuild:clean": "expo prebuild --clean", + "beta:ios": "bundle exec fastlane ios beta", + "listing:ios": "bundle exec fastlane ios listing" }, "dependencies": { "@react-navigation/native": "7.2.5", diff --git a/example/src/App.tsx b/example/src/App.tsx index feee9be..c2cd48f 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -6,25 +6,19 @@ 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'; import IridescenceScreen from './screens/IridescenceScreen'; import LiquidChromeScreen from './screens/LiquidChromeScreen'; import SilkScreen from './screens/SilkScreen'; import CampfireScreen from './screens/CampfireScreen'; import CalicoSwirlScreen from './screens/CalicoSwirlScreen'; import AuroraScreen from './screens/AuroraScreen'; -import CircularGradientScreen from './screens/CircularGradientScreen'; import SiriOrbScreen from './screens/SiriOrbScreen'; import HoloCardScreen from './screens/HoloCardScreen'; import WeatherScreen from './screens/WeatherScreen'; -import ScrollReactiveScreen from './screens/ScrollReactiveScreen'; import TouchFieldScreen from './screens/TouchFieldScreen'; import ThanosScreen from './screens/ThanosScreen'; import AiShimmerScreen from './screens/AiShimmerScreen'; @@ -39,7 +33,6 @@ import LighterScreen from './screens/LighterScreen'; import TelegramDissolveScreen from './screens/TelegramDissolveScreen'; import BirthdayCandleScreen from './screens/BirthdayCandleScreen'; import ParticleDispersionScreen from './screens/ParticleDispersionScreen'; -import FoamLabScreen from './screens/FoamLabScreen'; import VoiceDeleteScreen from './screens/VoiceDeleteScreen'; import Magic8BallScreen from './screens/Magic8BallScreen'; import DandelionScreen from './screens/DandelionScreen'; @@ -73,16 +66,6 @@ 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 ( @@ -109,20 +92,6 @@ export default function App() { contentStyle: { backgroundColor: '#121212' }, }} /> - - - - - + - - + @@ -162,7 +122,7 @@ export default function App() { - - + - + - + - - + + diff --git a/example/src/catalog/effects.ts b/example/src/catalog/effects.ts index d6caf88..01f60b7 100644 --- a/example/src/catalog/effects.ts +++ b/example/src/catalog/effects.ts @@ -33,25 +33,10 @@ export const SECTIONS: CatalogSection[] = [ 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', + line: 'Run a focus timer, then flip your phone to restart it', screen: 'HourglassExample', image: require('../../assets/catalog/hourglass.jpg'), }, @@ -98,6 +83,7 @@ export const SECTIONS: CatalogSection[] = [ line: 'Blow on your phone and the seeds drift away', screen: 'DandelionExample', image: require('../../assets/catalog/dandelion.jpg'), + hero: require('../../assets/catalog/dandelion-hero.jpg'), }, ], }, @@ -109,31 +95,31 @@ export const SECTIONS: CatalogSection[] = [ { id: 'cracked-screen', title: 'Cracked Screen', - line: 'Tap hard and the glass breaks where you hit it', + line: 'Tap 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: 'hinge-crack', + title: 'Hinge Crack', + line: 'A folding screen gives out along its crease', + screen: 'HingeCrackExample', + image: require('../../assets/catalog/hinge-crack.jpg'), }, { id: 'crt-off', title: 'CRT Off', - line: 'Close the app like an old TV', + line: 'Switch off like an old TV', screen: 'CrtOffExample', image: require('../../assets/catalog/crt-off.jpg'), }, { - id: 'thanos', - title: 'Thanos', + id: 'dust', + title: 'Dust', line: 'Tap to snap a card into dust', - screen: 'ThanosExample', - image: require('../../assets/catalog/thanos.jpg'), + screen: 'DustExample', + image: require('../../assets/catalog/dust.jpg'), }, { id: 'particle-dispersion', @@ -152,7 +138,7 @@ export const SECTIONS: CatalogSection[] = [ { id: 'scratch-off', title: 'Scratch-off', - line: 'Scratch the foil to reveal your prize', + line: 'Scratch the foil to find three matching symbols', screen: 'ScratchOffExample', image: require('../../assets/catalog/scratch-off.jpg'), }, @@ -166,7 +152,7 @@ export const SECTIONS: CatalogSection[] = [ { id: 'flashlight', title: 'Flashlight', - line: 'The Dynamic Island torch. Swipe to shape the beam', + line: 'Tilt a beam around a dark corridor and shape it', screen: 'FlashlightExample', image: require('../../assets/catalog/flashlight.jpg'), }, @@ -178,32 +164,39 @@ export const SECTIONS: CatalogSection[] = [ 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: 'metal-card', + title: 'Metal Card', + line: 'Tap and the card lifts as light runs across it', + screen: 'MetalCardExample', + image: require('../../assets/catalog/metal-card.jpg'), }, { - id: 'namedrop', - title: 'NameDrop', - line: 'Bring two iPhones together and swap contacts', - screen: 'NameDropExample', - image: require('../../assets/catalog/namedrop.jpg'), + id: 'wave-reveal', + title: 'Wave Reveal', + line: 'A wave of light carries you from morning to dusk', + screen: 'WaveRevealExample', + image: require('../../assets/catalog/wave-reveal.jpg'), }, { id: 'touch-field', - title: 'Sauron Eye', - line: 'The lidless Eye, wreathed in flame', + title: 'The Eye', + line: 'A burning eye that follows your finger', 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: 'lighter', + title: 'Lighter', + line: 'Tap to strike. The flame stays upright as you tilt', + screen: 'LighterExample', + image: require('../../assets/catalog/lighter.jpg'), + }, + { + id: 'beer', + title: 'Beer', + line: 'Tilt to slosh, rotate to empty it, tap to refill', + screen: 'BeerExample', + image: require('../../assets/catalog/beer.jpg'), }, ], }, @@ -213,18 +206,18 @@ export const SECTIONS: CatalogSection[] = [ 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: 'mystic-ball', + title: 'Mystic Ball', + line: 'Shake your phone and the answer surfaces through the mist', + screen: 'MysticBallExample', + image: require('../../assets/catalog/mystic-ball.jpg'), }, { - id: 'polaroid', - title: 'Polaroid', + id: 'instant-camera', + title: 'Instant Camera', line: 'Snap a photo and shake it while it develops', - screen: 'PolaroidExample', - image: require('../../assets/catalog/polaroid.jpg'), + screen: 'InstantCameraExample', + image: require('../../assets/catalog/instant-camera.jpg'), }, ], }, @@ -236,16 +229,16 @@ export const SECTIONS: CatalogSection[] = [ { id: 'ai-orb', title: 'AI Orb', - line: 'A morphing orb that listens while it thinks', + line: 'Morphing thinking blob', screen: 'AiOrbExample', image: require('../../assets/catalog/ai-orb.jpg'), }, { - id: 'siri-orb', - title: 'Siri Orb', + id: 'glow-orb', + title: 'Glow Orb', line: 'Luminous breathing sphere with swirling colors', - screen: 'SiriOrbStatic', - image: require('../../assets/catalog/siri-orb.jpg'), + screen: 'GlowOrbStatic', + image: require('../../assets/catalog/glow-orb.jpg'), }, { id: 'ai-voice', @@ -269,25 +262,25 @@ export const SECTIONS: CatalogSection[] = [ 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: 'send-effects', + title: 'Message Flourishes', + line: 'Pick a chip and your message lands with a flourish', + screen: 'SendEffectsExample', + image: require('../../assets/catalog/send-effects.jpg'), }, { - id: 'telegram-spoiler', - title: 'Telegram Spoiler', + id: 'hidden-text', + title: 'Hidden Text', line: 'Tap the hidden text to reveal it for a moment', - screen: 'TelegramSpoilerExample', - image: require('../../assets/catalog/telegram-spoiler.jpg'), + screen: 'HiddenTextExample', + image: require('../../assets/catalog/hidden-text.jpg'), }, { - id: 'telegram-dissolve', - title: 'Telegram Dissolve', + id: 'message-dissolve', + title: 'Message Dissolve', line: 'Delete a message and watch it turn to dust', - screen: 'TelegramDissolveExample', - image: require('../../assets/catalog/telegram-dissolve.jpg'), + screen: 'MessageDissolveExample', + image: require('../../assets/catalog/message-dissolve.jpg'), }, { id: 'voice-delete', @@ -299,7 +292,7 @@ export const SECTIONS: CatalogSection[] = [ { id: 'banners', title: 'Banners', - line: 'iOS notification banners with shader effects', + line: 'Alert banners with living shader backgrounds', screen: 'BannersExample', image: require('../../assets/catalog/banners.jpg'), }, @@ -356,7 +349,7 @@ export const SECTIONS: CatalogSection[] = [ { id: 'weather', title: 'Weather', - line: 'A full forecast app with living skies', + line: 'Skies that change with the weather', screen: 'WeatherExample', image: require('../../assets/catalog/weather.jpg'), }, @@ -374,27 +367,6 @@ export const SECTIONS: CatalogSection[] = [ 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'), - }, ], }, ]; @@ -404,7 +376,7 @@ const ALL_EFFECTS = SECTIONS.flatMap((section) => section.effects); const FEATURED_IDS = [ 'lava-lamp', 'cracked-screen', - 'lighter', + 'dandelion', 'birthday-candle', 'aurora', ]; @@ -422,8 +394,13 @@ const ALPHABETICAL_ENTRIES = SECTIONS.flatMap((section) => })) ).sort((a, b) => a.effect.title.localeCompare(b.effect.title)); +const MIN_QUERY_LENGTH = 2; + export function searchEffects(query: string) { const needle = query.trim().toLowerCase(); + if (needle.length < MIN_QUERY_LENGTH) { + return ALL_EFFECTS; + } return ALPHABETICAL_ENTRIES.filter((entry) => entry.haystack.includes(needle) ).map((entry) => entry.effect); diff --git a/example/src/components/BeerGlass.tsx b/example/src/components/BeerGlass.tsx index 4f20eb2..06d8839 100644 --- a/example/src/components/BeerGlass.tsx +++ b/example/src/components/BeerGlass.tsx @@ -23,10 +23,8 @@ type Props = Omit< foamColor?: ColorInput; }; -// The head is a photograph of foam (iBeer's own trick — every procedural -// head read as drawn). Foam over beer, its lacy bottom line at 0.615 of the -// height; the shader keys the photo's beer out. Stock image: clear its -// licence before shipping. +// Foam over beer, drawn by assets/tools/generate-assets.py, its lacy bottom +// line at 0.615 of the height. The shader keys the image's beer out. const FOAM_PHOTO = require('../../assets/foam.jpg'); /** diff --git a/example/src/components/BirthdayFireworks.tsx b/example/src/components/BirthdayFireworks.tsx index 2fd425b..5e961e1 100644 --- a/example/src/components/BirthdayFireworks.tsx +++ b/example/src/components/BirthdayFireworks.tsx @@ -9,7 +9,7 @@ type Props = ViewProps & { }; /** - * The iOS birthday-notification banner: glitter-dust fireworks popping at + * A birthday banner: glitter-dust fireworks popping at * random spots in the dark, behind the notification text. Each burst is an * expanding sphere of twinkling sparkle cells — no particles, just a hashed * glitter field gated by the shell's radius — with a brief white flash at @@ -17,8 +17,8 @@ type Props = ViewProps & { * silver, gold) picked per burst. */ export default function BirthdayFireworks({ - caption = 'Birthday · Found in Contacts', - title = 'Today, Jun 9', + caption = 'Happy birthday', + title = 'Make a wish', style, ...rest }: Props) { @@ -76,7 +76,7 @@ fn main(@location(0) ndc: vec2) -> @location(0) vec4 { ); // Six launchers on offset clocks → a few distinct balls in flight, with - // black space between them like the real notification. + // black space between them. for (var i = 0; i < 6; i = i + 1) { let fi = f32(i); let period = 2.9 + 1.7 * fract(fi * 0.6180339); diff --git a/example/src/components/CoinGeyser.tsx b/example/src/components/CoinGeyser.tsx index 1b58003..9b48f3d 100644 --- a/example/src/components/CoinGeyser.tsx +++ b/example/src/components/CoinGeyser.tsx @@ -13,7 +13,7 @@ type Props = ViewProps & { // with each burst. const ERUPTION_PERIOD_MS = 5200; -// More money, more coins: one coin per $300, within the shader's budget. +// One shader coin per 300 saved, within the shader's budget. const coinsForAmount = (amount: number) => Math.min(64, Math.max(10, Math.round(amount / 300))); @@ -99,10 +99,7 @@ export default function CoinGeyser({ return () => clearInterval(id); }, [scale]); - const title = `$${display.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; + const title = `${Math.round(display).toLocaleString('en-US')} coins`; return ( diff --git a/example/src/components/ScrollReactive.tsx b/example/src/components/ScrollReactive.tsx deleted file mode 100644 index e878eb0..0000000 --- a/example/src/components/ScrollReactive.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useMemo } from 'react'; -import type { ViewProps } from 'react-native'; -import { - ShaderView, - type ColorInput, - type ParamsSynchronizable, -} from 'react-native-effects'; - -type Props = ViewProps & { - /** - * Live scroll channel written into `u.live` every frame: `x` = progress - * (0..1), `y` = rubber-band overscroll. Drive it with `setParamsSynchronizable` from a scroll - * handler (see `useParamsSynchronizable`) so scrolling never re-renders React. - */ - paramsSynchronizable?: ParamsSynchronizable; - /** Contour line color (and the index lines). */ - colorA?: ColorInput; - /** Secondary tone for the index lines and the low-ground wash. */ - colorB?: ColorInput; - /** Animation speed multiplier. Default: 1.0 */ - speed?: number; -}; - -/** - * An animated topographic contour map. A noise elevation field is sliced into - * thin survey-map contour lines (every 5th an "index" line) drawn in `colorA` - * over black. `progress` (typically a normalized scroll offset) pans the terrain - * and tightens the contours, so the background drifts as the user scrolls. - * - * Lives in the example app rather than the library — it is a demo of what you - * can build on top of `ShaderView`, not a general-purpose library primitive. - */ -export default function ScrollReactive({ - paramsSynchronizable, - colorA = '#3457ff', - colorB = '#c026d3', - speed = 1.0, - ...viewProps -}: Props) { - const colors = useMemo(() => [colorA, colorB], [colorA, colorB]); - - return ( - - ); -} - -const SCROLL_REACTIVE_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; - -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 u2 = 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, u2.x), mix(c, d, u2.x), u2.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 < 5; i = i + 1) { - v = v + a * vnoise(p); - p = m * p; - a = a * 0.5; - } - return v; -} - -@fragment -fn main(@location(0) ndc: vec2) -> @location(0) vec4 { - let t = u.time.x; - let aspect = u.resolution.z; - let uv = ndc * 0.5 + 0.5; - var p = (uv - 0.5) * vec2(aspect, 1.0); - - // Live scroll channel (u.live): x = progress 0..1, y = overscroll. - let prog = clamp(u.live.x, 0.0, 1.0); - // Rubber-band overscroll (screen-heights): negative at the top, positive at - // the bottom. Lets the terrain keep reacting past either end of the list. - let over = u.live.y; - - // A slowly evolving elevation field. Scrolling pans the terrain upward and - // lifts the relief, so the contour map drifts and tightens as you read. - p = p * 1.7; - // Scroll gently pans the terrain — keep this small or a flick of the - // scrollview rockets the whole field across the screen. Overscroll adds an - // extra springy pan at both ends. - p.y = p.y + prog * 0.3 + over * 1.6; - var h = fbm(p + vec2(t * 0.012, t * 0.008)); - h = fbm(p + vec2(h, h) * (0.5 + prog * 0.15 + abs(over) * 0.5) + vec2(0.0, t * 0.01)); - - // Slice the field into topographic contour lines — density barely shifts - // with scroll so the map stays calm. - let bands = 25.0 + prog * 2.0; - let f = h * bands; - let d = abs(fract(f) - 0.5); // 0 exactly on a contour line - let aa = fwidth(f) * 1.1; // screen-space anti-aliasing falloff - let thickness = 0.14; // half-width of each contour line - let lineMask = 1.0 - smoothstep(thickness, thickness + aa, d); - - // Every 5th line is a brighter "index" contour, like a real survey map. - let idx = floor(f); - let isMajor = select(0.0, 1.0, (idx - 5.0 * floor(idx / 5.0)) < 0.5); - - let lineCol = mix(u.color0.rgb, u.color1.rgb, isMajor * 0.5); - var col = lineCol * lineMask * (0.5 + isMajor * 0.5); - - // A faint warm wash pooling in the low ground gives quiet depth between lines. - col = col + u.color1.rgb * smoothstep(0.55, 0.0, h) * 0.04; - - // Vignette to settle the edges into black. - let vd = uv - vec2(0.5, 0.5); - col = col * clamp(1.0 - dot(vd, vd) * 0.8, 0.4, 1.0); - - // A whisper of grain so the darks never band on real displays. - let grain = hash21(uv * u.resolution.xy + fract(t) * 311.0) - 0.5; - col = col + grain * 0.01; - - return vec4(clamp(col, vec3(0.0), vec3(1.0)), 1.0); -} -`; diff --git a/example/src/components/SiriEdgeGlow.tsx b/example/src/components/SiriEdgeGlow.tsx deleted file mode 100644 index 3a74cdd..0000000 --- a/example/src/components/SiriEdgeGlow.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { useMemo } from 'react'; -import type { ViewProps } from 'react-native'; -import { ShaderView } from 'react-native-effects'; - -type Props = ViewProps & { - /** Animation speed multiplier. Default: 1.0 */ - speed?: number; - /** Activity level 0.0 (idle) to 1.0 (listening). Default: 0.0 */ - activity?: number; -}; - -export default function SiriEdgeGlow({ - speed = 1.0, - activity = 0.0, - ...viewProps -}: Props) { - const params = useMemo(() => [activity], [activity]); - - return ( - - ); -} - -const SIRI_EDGE_GLOW_SHADER = /* wgsl */ ` -struct Uniforms { - resolution: vec4, - time: vec4, - color0: vec4, - color1: vec4, - params0: vec4, - params1: vec4, -}; -@group(0) @binding(0) var u: Uniforms; - -fn hash(p: vec2) -> f32 { - var p3 = fract(vec3(p.x, p.y, p.x) * 0.1031); - p3 += dot(p3, vec3(p3.y + 33.33, p3.z + 33.33, p3.x + 33.33)); - return fract((p3.x + p3.y) * p3.z); -} - -fn noise(p: vec2) -> f32 { - let i = floor(p); - let f = fract(p); - let s = f * f * (3.0 - 2.0 * f); - return mix( - mix(hash(i), hash(i + vec2(1.0, 0.0)), s.x), - mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), s.x), - s.y - ); -} - -fn fbm3(p_in: vec2) -> f32 { - var p = p_in; - var v = 0.0; - var a = 0.5; - for (var i = 0; i < 3; i++) { - v += a * noise(p); - p = p * 2.01 + vec2(1.7, 9.2); - a *= 0.5; - } - return v; -} - -fn siriPalette(t: f32) -> vec3 { - let magenta = vec3(0.95, 0.05, 0.55); - let purple = vec3(0.50, 0.05, 0.95); - let cyan = vec3(0.0, 0.85, 0.95); - let pink = vec3(1.0, 0.25, 0.55); - let blue = vec3(0.15, 0.25, 0.95); - - let tt = fract(t); - if (tt < 0.2) { - return mix(magenta, purple, tt / 0.2); - } else if (tt < 0.4) { - return mix(purple, blue, (tt - 0.2) / 0.2); - } else if (tt < 0.6) { - return mix(blue, cyan, (tt - 0.4) / 0.2); - } else if (tt < 0.8) { - return mix(cyan, pink, (tt - 0.6) / 0.2); - } - return mix(pink, magenta, (tt - 0.8) / 0.2); -} - -// Rounded rectangle signed distance field -// p = point, b = half-size, r = corner radius -fn sdRoundedRect(p: vec2, b: vec2, r: f32) -> f32 { - let q = abs(p) - b + 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 time = u.time.x; - let activity = clamp(u.params0.x, 0.0, 1.0); - let aspect = u.resolution.z; - - let uv = ndc * 0.5 + 0.5; - - // Work in pixel-like coords centered at screen middle - // p goes from (-0.5, -0.5) to (0.5, 0.5) - let p = vec2((uv.x - 0.5) * aspect, uv.y - 0.5); - - // Rounded rect matching iPhone shape - // Half-size of the screen rect, corner radius ~0.06 normalized - let halfSize = vec2(aspect * 0.5, 0.5); - let cornerR = 0.07; - - // SDF: negative inside, positive outside, 0 on border - let d = sdRoundedRect(p, halfSize, cornerR); - - // We want glow only near the border (d ~ 0), fading inward - // d is negative inside the rect; border is at d=0 - // Glow peaks near the edge and fades toward center - let borderDist = abs(d); - - // ---- Noise for wispy organic inner edge ---- - let n1 = fbm3(uv * 8.0 + vec2(time * 0.1, time * 0.07)) - 0.5; - let n2 = fbm3(uv * 5.0 + vec2(-time * 0.08, time * 0.12) + 3.0) - 0.5; - let warp = (n1 * 0.6 + n2 * 0.4) * 0.012 * (1.0 + activity * 0.5); - - // ---- Main glow: tight gaussian around the rounded border ---- - let warpedDist = borderDist + warp; - let glowWidth = 0.025; - let glow = exp(-warpedDist * warpedDist / (glowWidth * glowWidth)); - - // ---- Color: rotating around the perimeter ---- - // Use atan2 of centered position for perimeter angle - let sa = sin(atan2(p.y, p.x)); - let ca = cos(atan2(p.y, p.x)); - let perimT = (sa * 0.5 + ca * 0.3) * 0.5 + 0.5; - let rot = time * 0.18; - let edgeColor = siriPalette(perimT + rot); - - // ---- Breathing pulse (synced with SiriOrb) ---- - let pulseSpeed = 2.2 + activity * 1.8; - let pulse = 0.85 + 0.15 * sin(time * pulseSpeed); - - // ---- Secondary inner glow (dimmer, offset inward) ---- - let innerDist = max(0.0, -d - 0.015) + warp * 0.5; - let innerGlow = exp(-innerDist * innerDist / (0.018 * 0.018)) * 0.25; - let innerColor = siriPalette(perimT + rot + 0.12); - - // ---- Compose ---- - let intensity = 0.6 + activity * 0.3; - var col = edgeColor * glow * pulse * intensity; - col += innerColor * innerGlow * pulse * intensity; - - // Only show inside the screen (d < 0) - let insideMask = smoothstep(0.002, -0.002, d); - col *= insideMask; - - let alpha = clamp((glow + innerGlow) * pulse * intensity * insideMask, 0.0, 1.0); - - return vec4(col, alpha); -} -`; diff --git a/example/src/components/SiriOrb.tsx b/example/src/components/SiriOrb.tsx index 2c5f2da..46dee56 100644 --- a/example/src/components/SiriOrb.tsx +++ b/example/src/components/SiriOrb.tsx @@ -4,9 +4,9 @@ import { ShaderView } from 'react-native-effects'; import type { ColorInput } from 'react-native-effects'; type Props = ViewProps & { - /** Primary orb color (magenta/purple). */ + /** Primary orb color. */ color?: ColorInput; - /** Accent color for chromatic edges (cyan). */ + /** Second orb color, swirled through the first. */ accentColor?: ColorInput; /** Animation speed multiplier. Default: 1.0 */ speed?: number; @@ -17,8 +17,8 @@ type Props = ViewProps & { }; export default function SiriOrb({ - color = '#a855f6', - accentColor = '#06b6d4', + color = '#d9a441', + accentColor = '#0f6b5a', speed = 1.0, activity = 0.0, bloom = 0.6, @@ -29,7 +29,7 @@ export default function SiriOrb({ return ( , time: vec4, @@ -80,26 +80,8 @@ fn fbm3(p_in: vec2) -> f32 { return v; } -// Palette: vivid neon Siri colors -fn siriPalette(t: f32) -> vec3 { - // Deep magenta -> electric purple -> hot cyan -> warm pink -> back - let magenta = vec3(0.95, 0.05, 0.55); - let purple = vec3(0.50, 0.05, 0.95); - let cyan = vec3(0.0, 0.85, 0.95); - let pink = vec3(1.0, 0.25, 0.55); - let blue = vec3(0.15, 0.25, 0.95); - - let tt = fract(t); - if (tt < 0.2) { - return mix(magenta, purple, tt / 0.2); - } else if (tt < 0.4) { - return mix(purple, blue, (tt - 0.2) / 0.2); - } else if (tt < 0.6) { - return mix(blue, cyan, (tt - 0.4) / 0.2); - } else if (tt < 0.8) { - return mix(cyan, pink, (tt - 0.6) / 0.2); - } - return mix(pink, magenta, (tt - 0.8) / 0.2); +fn orbPalette(t: f32) -> vec3 { + return mix(u.color0.rgb, u.color1.rgb, 0.5 + 0.5 * sin(t * 6.2831853)); } @fragment @@ -121,33 +103,19 @@ fn main(@location(0) ndc: vec2) -> @location(0) vec4 { let radius = 0.22 + breathe; // ---- Internal swirling colors ---- - // Use sin/cos of angle to avoid atan2 discontinuity let warpAmt = 1.5 + activity * 1.5; - let angle = atan2(centered.y, centered.x); - let sa = sin(angle); - let ca = cos(angle); - - // Seamless polar-like coords using sin/cos (no wrapping seam) - let polar1 = vec2(ca + dist * 2.0, sa + dist * 2.0); - let polar2 = vec2(sa * 1.5 + dist * 3.0, ca * 1.5 - dist * 3.0); // Two layers of warped noise for rich color variation - let warp1 = fbm3(polar1 + vec2(time * 0.2, time * 0.15)) - 0.5; - let warp2 = fbm3(polar2 + vec2(-time * 0.18, time * 0.22) + 5.0) - 0.5; - - // Color index: use sin/cos blend instead of raw angle to stay seamless - let colorIdx = (sa * 0.5 + ca * 0.3) * 0.5 + 0.5 + warp1 * warpAmt * 0.3 + warp2 * warpAmt * 0.2 + time * 0.05; + let warp1 = fbm3(centered * 3.5 + vec2(time * 0.2, time * 0.15)) - 0.5; + let warp2 = fbm3(centered * 6.0 + vec2(-time * 0.18, time * 0.22) + 5.0) - 0.5; - // Get vivid palette color - var orbCol = siriPalette(colorIdx); + let colorIdx = warp1 * warpAmt + warp2 * warpAmt * 0.6 + time * 0.05; - // Boost saturation and vibrancy - let lum = dot(orbCol, vec3(0.299, 0.587, 0.114)); - orbCol = mix(vec3(lum), orbCol, 1.6); + var orbCol = orbPalette(colorIdx); // Add radial brightness variation (brighter center) let normDist = dist / radius; - let coreBright = 1.0 + (1.0 - smoothstep(0.0, 0.7, normDist)) * 0.8; + let coreBright = 1.0 + (1.0 - smoothstep(0.0, 0.8, normDist)) * 0.3; orbCol *= coreBright; // Add subtle noise shimmer @@ -161,49 +129,32 @@ fn main(@location(0) ndc: vec2) -> @location(0) vec4 { // Bright rim at the edge (Fresnel-like) let rimStart = radius - edgeWidth * 3.0; let rim = smoothstep(rimStart, radius, dist) * (1.0 - smoothstep(radius, radius + edgeWidth, dist)); - let rimColor = siriPalette(colorIdx + 0.3) * 1.5; + let rimColor = orbPalette(colorIdx + 0.3) * 1.5; // ---- Specular highlights ---- let sp1 = vec2(cos(time * 0.6) * 0.1, sin(time * 0.8) * 0.08); - let spec1 = exp(-length(centered - sp1) * length(centered - sp1) * 350.0) * 1.2; + let spec1 = exp(-length(centered - sp1) * length(centered - sp1) * 350.0) * 0.35; let sp2 = vec2(cos(time * -0.4 + 2.0) * 0.06, sin(time * 0.55 + 1.0) * 0.07); - let spec2 = exp(-length(centered - sp2) * length(centered - sp2) * 600.0) * 0.7; + let spec2 = exp(-length(centered - sp2) * length(centered - sp2) * 600.0) * 0.2; // ---- Compose orb ---- var col = orbCol * mask; - col += rimColor * rim * 0.6; + col += rimColor * rim * 0.3; col += vec3(1.0, 0.95, 1.0) * (spec1 + spec2) * mask; // ---- Outer glow / bloom ---- - // Multi-colored glow that bleeds past the edge let glowFalloff = 80.0; let gd = max(0.0, dist - radius + 0.01); let glowMask = exp(-gd * gd * glowFalloff) * bloomIntensity; - // Chromatic glow: different colors in different directions (seamless) - let glowCol = siriPalette((sa * 0.5 + ca * 0.3) * 0.5 + 0.5 + time * 0.03); - col += glowCol * glowMask * 0.7; + col += u.color0.rgb * glowMask * (1.0 - mask) * 0.7; // Wide subtle ambient glow let ambientGlow = exp(-dist * dist * 12.0) * 0.08 * bloomIntensity; - let ambientCol = siriPalette(time * 0.02 + 0.5); - col += ambientCol * ambientGlow; - - // ---- Chromatic aberration at edges ---- - let caStr = smoothstep(radius * 0.5, radius * 1.2, dist) * 0.02; - let caOff = normalize(centered + vec2(0.0001)) * caStr; - - let distR = length(centered + caOff); - let distB = length(centered - caOff); - let maskR = 1.0 - smoothstep(radius - edgeWidth, radius + edgeWidth * 0.5, distR); - let maskB = 1.0 - smoothstep(radius - edgeWidth, radius + edgeWidth * 0.5, distB); - - // Shift red outward, blue inward for energy feel - col.r = col.r * 0.5 + orbCol.r * coreBright * maskR * 0.5; - col.b = col.b * 0.5 + orbCol.b * coreBright * maskB * 0.5; + col += u.color0.rgb * ambientGlow; - // Tone mapping — preserve vibrancy + // Tone mapping col = pow(col, vec3(0.92)); col = clamp(col, vec3(0.0), vec3(1.0)); diff --git a/example/src/components/StreakFire.tsx b/example/src/components/StreakFire.tsx index bd5f2d4..487742b 100644 --- a/example/src/components/StreakFire.tsx +++ b/example/src/components/StreakFire.tsx @@ -15,7 +15,7 @@ type Props = ViewProps & { * climb. The blaze stays bottom-weighted so the title keeps its contrast. */ export default function StreakFire({ - caption = 'Duolingo · Don’t lose your streak', + caption = 'Sprout · Keep it going', title = '365 day streak 🔥', style, ...rest diff --git a/example/src/components/ThinkingOrb.tsx b/example/src/components/ThinkingOrb.tsx index d4bce6e..27891ff 100644 --- a/example/src/components/ThinkingOrb.tsx +++ b/example/src/components/ThinkingOrb.tsx @@ -3,7 +3,7 @@ import { ShaderView, type ShaderViewProps } from 'react-native-effects'; type Props = Omit; /** - * A Gemini-style "thinking" blob — a soft metaball that gently morphs its shape + * A "thinking" blob, a soft metaball that gently morphs its shape * with a smooth dark→blue gradient fill, an inner moving highlight and a clean * single-colour glow (no plasma spilling outside, no hard rim). Render it * `transparent` in a square container. Pure procedural — no texture sampling. diff --git a/example/src/components/Thunderstorm.tsx b/example/src/components/Thunderstorm.tsx index ebdd239..31e8894 100644 --- a/example/src/components/Thunderstorm.tsx +++ b/example/src/components/Thunderstorm.tsx @@ -9,15 +9,15 @@ type Props = ViewProps & { }; /** - * A severe-weather banner with a live storm inside. A brooding fbm cloud deck + * A stormy weather banner with a live storm inside. A brooding fbm cloud deck * hangs across the top; every couple of seconds a branching lightning bolt * tears a noise-driven path from the clouds to the bottom edge, strobing * 2–3 times and flash-lighting the whole card (clouds included) while faint * rain streaks fall through the glow. */ export default function Thunderstorm({ - caption = 'Weather Alert · Severe', - title = 'Storm until 9 PM', + caption = 'Weather · Rainy evening', + title = 'Thunder later tonight', style, ...rest }: Props) { diff --git a/example/src/components/cracks/EffectCaption.tsx b/example/src/components/cracks/EffectCaption.tsx new file mode 100644 index 0000000..f4bc7fe --- /dev/null +++ b/example/src/components/cracks/EffectCaption.tsx @@ -0,0 +1,31 @@ +import { StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +/** Stays on screen so nobody can read the damage as their real display. */ +export default function EffectCaption() { + const insets = useSafeAreaInsets(); + return ( + + Just an effect. Your screen is fine. + + ); +} + +const styles = StyleSheet.create({ + pill: { + position: 'absolute', + alignSelf: 'center', + paddingHorizontal: 14, + paddingVertical: 7, + borderRadius: 16, + backgroundColor: 'rgba(0,0,0,0.62)', + }, + text: { + color: '#fff', + fontSize: 13, + fontWeight: '600', + }, +}); diff --git a/example/src/components/cracks/LockScreen.tsx b/example/src/components/cracks/LockScreen.tsx index b253095..46d3c72 100644 --- a/example/src/components/cracks/LockScreen.tsx +++ b/example/src/components/cracks/LockScreen.tsx @@ -8,36 +8,7 @@ import { } 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', -]; +const SERIF = 'Georgia'; function pad(n: number) { return n < 10 ? `0${n}` : `${n}`; @@ -55,39 +26,12 @@ function useClock(live: boolean) { 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 ( - - ); + return `${pad(now.getHours())} ${pad(now.getMinutes())}`; } 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; @@ -97,85 +41,33 @@ type LockScreenProps = { }; /** - * 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. + * An abstract scene for the glass effects: a full-bleed photo with a small + * serif clock in the corner. 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)', + clockColor = 'rgba(255,255,255,0.9)', 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; + const time = useClock(liveClock); return ( - - - - {date} - - {time} - - - - {notification ? ( - - - - - - - {notification.title} - now - - - {notification.body} - - - - ) : null} - - - - - - - - - - - + {time} + ); } @@ -195,86 +87,12 @@ const styles = StyleSheet.create({ ...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)', + left: 24, + fontFamily: SERIF, + fontSize: 44, + fontStyle: 'italic', + fontVariant: ['tabular-nums'], }, }); diff --git a/example/src/components/dandelion/Dandelion.tsx b/example/src/components/dandelion/Dandelion.tsx index 3bc61c7..6f4c825 100644 --- a/example/src/components/dandelion/Dandelion.tsx +++ b/example/src/components/dandelion/Dandelion.tsx @@ -37,7 +37,7 @@ type DandelionProps = Omit< * translated by the wind distance since its band let go, drifting up, * tumbling slowly and fading out. * - * Same fragment-only particle trick as the Telegram dissolve: the wind is a + * Same fragment-only particle trick as the message dissolve: the wind is a * smooth field of the origin, inverted per fragment to find the neighbourhood * of cells that could land here, then gathered from a 5x5 window. */ diff --git a/example/src/components/dispersion/ParticleDispersion.tsx b/example/src/components/dispersion/ParticleDispersion.tsx index 5489f75..29f5c5e 100644 --- a/example/src/components/dispersion/ParticleDispersion.tsx +++ b/example/src/components/dispersion/ParticleDispersion.tsx @@ -42,7 +42,7 @@ type ParticleDispersionProps = Omit< * from the top down both sides while the cut-out shrinks a little and settles * in the middle of the screen. * - * Same fragment-only particle trick as the Telegram dissolve: each ~1.3pt + * Same fragment-only particle trick as the message dissolve: each ~1.3pt * cell of the background is one grain, released in order of its distance * from the subject (precomputed into the atlas), carried by a smooth wind * field that is inverted per fragment, and gathered from a 5×5 cell window. diff --git a/example/src/components/flashlight/TorchControls.tsx b/example/src/components/flashlight/TorchControls.tsx new file mode 100644 index 0000000..c0d6c81 --- /dev/null +++ b/example/src/components/flashlight/TorchControls.tsx @@ -0,0 +1,182 @@ +import { useState } from 'react'; +import { + Pressable, + StyleSheet, + Text, + View, + type AccessibilityActionEvent, + type LayoutChangeEvent, +} from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; + +const THUMB_SIZE = 22; +const ACCESSIBILITY_STEP = 0.1; +const ACCESSIBILITY_ACTIONS = [ + { name: 'increment' }, + { name: 'decrement' }, +] as const; + +function clamp01(v: number) { + return Math.max(0, Math.min(1, v)); +} + +type TorchSliderProps = { + label: string; + value: number; + onChange: (value: number) => void; +}; + +function TorchSlider({ label, value, onChange }: TorchSliderProps) { + const [trackWidth, setTrackWidth] = useState(1); + + const onLayout = (e: LayoutChangeEvent) => + setTrackWidth(e.nativeEvent.layout.width); + const setFromTouch = (x: number) => onChange(clamp01(x / trackWidth)); + const pan = Gesture.Pan() + .runOnJS(true) + .minDistance(0) + .onBegin((e) => setFromTouch(e.x)) + .onUpdate((e) => setFromTouch(e.x)); + + const onAccessibilityAction = (e: AccessibilityActionEvent) => { + const direction = e.nativeEvent.actionName === 'increment' ? 1 : -1; + onChange(clamp01(value + direction * ACCESSIBILITY_STEP)); + }; + + return ( + + {label} + + + + + + + + + ); +} + +type TorchControlsProps = { + isOn: boolean; + beam: number; + level: number; + bottomInset: number; + onToggle: () => void; + onBeamChange: (value: number) => void; + onLevelChange: (value: number) => void; +}; + +/** The light switch and the two sliders that shape the beam. */ +export default function TorchControls({ + isOn, + beam, + level, + bottomInset, + onToggle, + onBeamChange, + onLevelChange, +}: TorchControlsProps) { + return ( + + + + [ + styles.toggle, + isOn && styles.toggleOn, + pressed && styles.togglePressed, + ]} + accessibilityRole="switch" + accessibilityState={{ checked: isOn }} + > + + {isOn ? 'Switch off' : 'Switch on'} + + + + ); +} + +const styles = StyleSheet.create({ + panel: { + position: 'absolute', + left: 28, + right: 28, + gap: 10, + padding: 16, + borderRadius: 18, + backgroundColor: 'rgba(12, 12, 14, 0.78)', + }, + sliderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + label: { + width: 48, + color: 'rgba(255, 255, 255, 0.72)', + fontSize: 14, + fontWeight: '500', + }, + track: { + flex: 1, + height: 32, + justifyContent: 'center', + }, + groove: { + position: 'absolute', + left: 0, + right: 0, + height: 3, + borderRadius: 2, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + }, + fill: { + position: 'absolute', + left: 0, + height: 3, + borderRadius: 2, + backgroundColor: '#f2e6c8', + }, + thumb: { + position: 'absolute', + width: THUMB_SIZE, + height: THUMB_SIZE, + borderRadius: THUMB_SIZE / 2, + backgroundColor: '#f2e6c8', + }, + toggle: { + alignItems: 'center', + marginTop: 4, + paddingVertical: 11, + borderRadius: 12, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + toggleOn: { + backgroundColor: '#f2e6c8', + }, + togglePressed: { + opacity: 0.7, + }, + toggleText: { + color: '#fff', + fontSize: 15, + fontWeight: '600', + }, + toggleTextOn: { + color: '#17150f', + }, +}); diff --git a/example/src/components/flashlight/TorchIsland.tsx b/example/src/components/flashlight/TorchIsland.tsx deleted file mode 100644 index ad976a5..0000000 --- a/example/src/components/flashlight/TorchIsland.tsx +++ /dev/null @@ -1,292 +0,0 @@ -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/imessage/Bubble.tsx b/example/src/components/imessage/Bubble.tsx index 79edc31..094a125 100644 --- a/example/src/components/imessage/Bubble.tsx +++ b/example/src/components/imessage/Bubble.tsx @@ -1,27 +1,23 @@ import { useCallback, useRef, type ComponentRef } from 'react'; import { StyleSheet, Text, View, type LayoutChangeEvent } from 'react-native'; -import { captureRef } from 'react-native-view-shot'; import Animated, { Easing, withTiming } from 'react-native-reanimated'; -import type { BubbleTarget, Message, Rect } from './types'; +import type { BubbleTarget, Message } from './types'; -// Colours sampled from a real Messages thread in the dark appearance. -export const IM_BLUE = '#4C95F7'; -export const IM_GREY = '#262629'; -export const IM_BG = '#000'; -export const IM_MUTED = '#8E8E93'; -export const IM_CHROME = '#181818'; -const RADIUS = 20; +export const IM_BLUE = '#2D7D6A'; +export const IM_GREY = '#2A2F36'; +export const IM_BG = '#0E1216'; +export const IM_CHROME = '#181E24'; +const RADIUS = 12; type ViewHandle = ComponentRef; -// Measure and snapshot only once the entrance animation has finished and the -// thread has scrolled to the end; earlier, the snapshot would catch the -// bubble mid-fade and the rect would be where it was, not where it is. +// Measure only once the entrance animation has finished and the thread has +// scrolled to the end; earlier, the rect would be where it was, not where it is. const ENTER_MS = 260; const MEASURE_DELAY_MS = ENTER_MS + 60; -// Bubbles ease in from their tail corner, the way Messages lands them: a -// short fade with a slight rise and grow, no overshoot. +// Bubbles ease in from their low corner: a short fade with a slight rise and +// grow, no overshoot. const EASE_OUT = Easing.out(Easing.cubic); const popIn = () => { 'worklet'; @@ -42,25 +38,17 @@ const popIn = () => { type Props = { msg: Message; - /** Only the last bubble of a run gets iMessage's tail. */ + /** The last bubble of a run gets a square corner on the sender's side. */ tail: boolean; - /** Report the window rect (and, when asked, a snapshot) once laid out. */ + /** Report the window rect once laid out. */ onTarget?: (target: BubbleTarget) => void; - wantSnapshot?: boolean; }; /** - * One iMessage bubble: a real `Text` in a rounded view with the tail drawn - * under it. A bubble that a screen effect will play for measures itself in - * the window once laid out, and snapshots itself when the effect (echo) - * needs its pixels. + * One chat bubble: a real `Text` in a rounded view. A bubble that a screen + * effect will play for measures itself in the window once laid out. */ -export default function Bubble({ - msg, - tail, - onTarget, - wantSnapshot = false, -}: Props) { +export default function Bubble({ msg, tail, onTarget }: Props) { const mine = msg.from === 'me'; const bubbleRef = useRef(null); const reported = useRef(false); @@ -71,33 +59,13 @@ export default function Bubble({ return; } reported.current = true; - setTimeout(async () => { - const node = bubbleRef.current; - if (!node) { - return; - } - try { - const rect = await new Promise((resolve) => - node.measureInWindow((x, y, w, h) => resolve({ x, y, w, h })) - ); - let snapshotUri: string | null = null; - if (wantSnapshot) { - const raw = await captureRef(bubbleRef, { - format: 'png', - quality: 1, - result: 'tmpfile', - }); - snapshotUri = raw.startsWith('file://') ? raw : `file://${raw}`; - } - onTarget({ rect, snapshotUri }); - } catch (err) { - if (__DEV__) { - console.warn('bubble capture failed', err); - } - } + setTimeout(() => { + bubbleRef.current?.measureInWindow((x, y, w, h) => + onTarget({ rect: { x, y, w, h } }) + ); }, MEASURE_DELAY_MS); }, - [onTarget, wantSnapshot] + [onTarget] ); const bg = mine ? IM_BLUE : IM_GREY; @@ -107,25 +75,15 @@ export default function Bubble({ style={[styles.wrap, mine ? styles.wrapMine : styles.wrapThem]} entering={popIn} > - {tail && ( - <> - - - - )} {msg.text} @@ -147,8 +105,7 @@ const styles = StyleSheet.create({ justifyContent: 'flex-start', }, wrap: { - // Messages caps a bubble at about two thirds of the screen. - maxWidth: '68%', + maxWidth: '74%', }, wrapMine: { transformOrigin: 'right bottom', @@ -167,35 +124,10 @@ const styles = StyleSheet.create({ fontSize: 17, lineHeight: 21, }, - // The tail is the CSS two-shape trick: a filled corner under the bubble, - // cut by a ground-coloured shape beside it. - tailA: { - position: 'absolute', - bottom: 0, - width: 20, - height: 24, + cornerMine: { + borderBottomRightRadius: 2, }, - tailAMine: { - right: -8, - borderBottomLeftRadius: 16, - }, - tailAThem: { - left: -8, - borderBottomRightRadius: 16, - }, - tailB: { - position: 'absolute', - bottom: 0, - width: 26, - height: 24, - backgroundColor: IM_BG, - }, - tailBMine: { - right: -26, - borderBottomLeftRadius: 10, - }, - tailBThem: { - left: -26, - borderBottomRightRadius: 10, + cornerThem: { + borderBottomLeftRadius: 2, }, }); diff --git a/example/src/components/imessage/ConfettiEffect.tsx b/example/src/components/imessage/ConfettiEffect.tsx index adfbde4..607f8cb 100644 --- a/example/src/components/imessage/ConfettiEffect.tsx +++ b/example/src/components/imessage/ConfettiEffect.tsx @@ -9,7 +9,7 @@ type Props = { }; /** - * iMessage's Confetti: a shower of paper pieces dropped from above the + * Confetti: a shower of paper pieces dropped from above the * screen that fall through the chat in front of the bubbles, tumbling and * swaying, and are gone once they clear the bottom. Every piece is a cell * in a column grid with its own drop time, so the first second is a dense diff --git a/example/src/components/imessage/EchoEffect.tsx b/example/src/components/imessage/EchoEffect.tsx index d446c89..0cb8575 100644 --- a/example/src/components/imessage/EchoEffect.tsx +++ b/example/src/components/imessage/EchoEffect.tsx @@ -8,19 +8,14 @@ export const ECHO_MS = 2400; type Props = { /** The sent bubble, window logical px. */ rect: Rect; - /** View-shot of that bubble; every echo is a copy of it. */ - snapshotUri: string; onDone: () => void; }; /** - * iMessage's Echo: the bubble is copied a couple of dozen times and the - * copies swirl out of it across the whole screen, shrinking a little and - * fading at the end of their flight, while the real bubble stays put. The copies are the bubble's own - * pixels: for every fragment each copy's transform is undone and the - * snapshot sampled where it lands inside the rect. + * Encore: rings in the outline of the bubble ripple out of it across the + * screen, thinning and fading as they travel, while the bubble stays put. */ -export default function EchoEffect({ rect, snapshotUri, onDone }: Props) { +export default function EchoEffect({ rect, onDone }: Props) { const paramsSynchronizable = useEffectProgress(ECHO_MS, onDone); return ( ); @@ -46,18 +40,18 @@ struct Uniforms { 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 COPIES: i32 = 22; -const STAGGER: f32 = 0.38; // share of the play spent launching copies -const GOLDEN: f32 = 2.39996; // sunflower angle between neighbouring slots -const SWIRL: f32 = 1.2; // radians a copy swings about the bubble in flight +const RINGS: i32 = 5; +const STAGGER: f32 = 0.14; // share of the play between two rings +const WIDTH: f32 = 2.2; // ring half-width, px +const WAKE: f32 = 46.0; // e-fold length of the glow behind a ring, px +const CORNER: f32 = 12.0; // bubble corner radius, px +const MINT = vec3(0.62, 0.95, 0.83); +const TEAL = vec3(0.18, 0.49, 0.42); -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 sdRoundBox(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; } @fragment @@ -71,54 +65,30 @@ fn main(@location(0) ndc: vec2) -> @location(0) vec4 { let res = u.resolution.xy / u.resolution.w; let q = vec2(uv.x, 1.0 - uv.y) * res; - let origin = u.params0.xy; let size = u.params0.zw; - let centre = origin + size * 0.5; + let centre = u.params0.xy + size * 0.5; + let d = sdRoundBox(q - centre, size * 0.5, CORNER); + let reach = res.y * 0.9; + let flight = 1.0 - STAGGER * f32(RINGS - 1); var rgb = vec3(0.0); var a = 0.0; - for (var i = 0; i < COPIES; i = i + 1) { - let fi = f32(i); - let h2 = hash21(vec2(fi, 7.9)); - // Copies leave one after another, then all fly on the same clock. - let ti = clamp((p - STAGGER * fi / f32(COPIES)) / (1.0 - STAGGER), 0.0, 1.0); - if (ti <= 0.0) { + for (var i = 0; i < RINGS; i = i + 1) { + let ti = clamp((p - STAGGER * f32(i)) / flight, 0.0, 1.0); + if (ti <= 0.0 || ti >= 1.0) { continue; } - let ease = 1.0 - pow(1.0 - ti, 2.4); - // Each copy owns a slot in a sunflower laid over the whole screen, so - // the swarm fills it evenly the way iMessage does, and swings into its - // slot about the bubble rather than flying straight, so the flight - // reads as a spiral. - let slot = vec2(cos(fi * GOLDEN), sin(fi * GOLDEN)) - * sqrt((fi + 0.5) / f32(COPIES)); - let dest = res * 0.5 + slot * res * vec2(0.44, 0.46); - let arm = dest - centre; - let turn = SWIRL * (1.0 - ease); - let ct = cos(turn); - let st = sin(turn); - let c = centre - + vec2(ct * arm.x - st * arm.y, st * arm.x + ct * arm.y) * ease; - let scale = mix(1.0, 0.7, ease); - // The swarm holds together and each copy thins only at the end of its - // flight, so the screen is full before anything is gone. - let alpha = 1.0 - smoothstep(0.65, 1.0, ti); - let rot = (h2 - 0.5) * 0.9 * ease; - - // Undo this copy's transform: where in the bubble does q come from? - let d = q - c; - let cr = cos(rot); - let sr = sin(rot); - let local = vec2(cr * d.x + sr * d.y, -sr * d.x + cr * d.y) / scale; - let t = (local + size * 0.5) / size; - if (t.x < 0.0 || t.x > 1.0 || t.y < 0.0 || t.y > 1.0) { - continue; - } - let s = textureSampleLevel(tex, samp, t, 0.0); - let w = s.a * alpha * (1.0 - a); - rgb = rgb + s.rgb * w; - a = a + w; + let x = d - reach * (1.0 - pow(1.0 - ti, 1.8)); + let ring = exp(-x * x / (2.0 * WIDTH * WIDTH)); + let wake = select(0.0, exp(x / WAKE) * 0.16, x < 0.0); + let fade = smoothstep(0.0, 0.06, ti) * pow(1.0 - ti, 1.4); + let inten = (ring + wake) * fade; + rgb = rgb + mix(MINT, TEAL, ti) * inten; + a = a + inten; } - return vec4(rgb, a); + + let outside = smoothstep(0.0, 2.0, d); + let alpha = clamp(a * outside, 0.0, 1.0); + return vec4(min(rgb * outside, vec3(alpha)), alpha); } `; diff --git a/example/src/components/imessage/EffectsSheet.tsx b/example/src/components/imessage/EffectsSheet.tsx deleted file mode 100644 index f792cfe..0000000 --- a/example/src/components/imessage/EffectsSheet.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { - Pressable, - ScrollView, - StyleSheet, - Text, - View, - useWindowDimensions, - type NativeScrollEvent, - type NativeSyntheticEvent, -} from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import Bubble, { IM_BLUE } from './Bubble'; -import ScreenEffectPlayer from './ScreenEffectPlayer'; -import { - EFFECT_LABEL, - SCREEN_EFFECTS, - type BubbleTarget, - type EffectId, - type Message, - type ScreenEffectId, -} from './types'; - -type Props = { - text: string; - onClose: () => void; - onSend: (effect: EffectId) => void; -}; - -/** - * The "Send with effect" sheet: the pending message shown as a real bubble - * and the screen effects in a pager, each previewing on the bubble the way - * iMessage previews before you commit. The arrow sends with the effect. - */ -export default function EffectsSheet({ text, onClose, onSend }: Props) { - const insets = useSafeAreaInsets(); - const { width } = useWindowDimensions(); - const [page, setPage] = useState(0); - const [target, setTarget] = useState(null); - const [playKey, setPlayKey] = useState(0); - const replay = useRef | null>(null); - - const effect: ScreenEffectId = SCREEN_EFFECTS[page] ?? 'echo'; - - // The preview bubble is remounted per effect so its snapshot is fresh for - // the effects that need one. - const preview: Message = { - id: `preview-${effect}`, - from: 'me', - text, - effect, - time: '', - }; - - const onDone = useCallback(() => { - replay.current = setTimeout(() => setPlayKey((k) => k + 1), 600); - }, []); - useEffect( - () => () => { - if (replay.current) { - clearTimeout(replay.current); - } - }, - [] - ); - useEffect(() => { - // A new effect starts its preview from the top. - setTarget(null); - setPlayKey((k) => k + 1); - }, [effect]); - - const onPage = useCallback( - (e: NativeSyntheticEvent) => { - const i = Math.round(e.nativeEvent.contentOffset.x / width); - setPage(Math.max(0, Math.min(SCREEN_EFFECTS.length - 1, i))); - }, - [width] - ); - - const player = target ? ( - - ) : null; - - return ( - - - Send with effect - - ✕ - - - - - - - - - - {SCREEN_EFFECTS.map((id) => ( - - {EFFECT_LABEL[id]} - - ))} - - - {SCREEN_EFFECTS.map((id, i) => ( - - ))} - - - Swipe to preview · tap ↑ to send - onSend(effect)} - style={styles.send} - accessibilityRole="button" - accessibilityLabel={`Send with ${EFFECT_LABEL[effect]}`} - > - ↑ - - - - - {player} - - ); -} - -const styles = StyleSheet.create({ - root: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0,0,0,0.78)', - }, - top: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 16, - paddingBottom: 10, - }, - title: { - color: '#fff', - fontSize: 15, - fontWeight: '600', - }, - close: { - position: 'absolute', - right: 16, - bottom: 4, - width: 32, - height: 32, - borderRadius: 16, - backgroundColor: 'rgba(118,118,128,0.3)', - alignItems: 'center', - justifyContent: 'center', - }, - closeText: { - color: '#fff', - fontSize: 15, - fontWeight: '700', - }, - stage: { - flex: 1, - justifyContent: 'flex-end', - paddingBottom: 28, - }, - bottom: { - paddingTop: 8, - }, - pager: { - height: 56, - }, - page: { - alignItems: 'center', - justifyContent: 'center', - }, - pageText: { - color: '#fff', - fontSize: 22, - fontWeight: '600', - }, - dots: { - flexDirection: 'row', - justifyContent: 'center', - gap: 7, - marginTop: 2, - }, - dot: { - width: 7, - height: 7, - borderRadius: 4, - backgroundColor: 'rgba(255,255,255,0.3)', - }, - dotOn: { - backgroundColor: '#fff', - }, - sendRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 20, - marginTop: 18, - }, - hint: { - color: '#8e8e93', - fontSize: 13, - }, - send: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: IM_BLUE, - alignItems: 'center', - justifyContent: 'center', - }, - sendArrow: { - color: '#fff', - fontSize: 22, - fontWeight: '700', - marginTop: -2, - }, -}); diff --git a/example/src/components/imessage/FireworksEffect.tsx b/example/src/components/imessage/FireworksEffect.tsx index 25bfc74..fe1b70b 100644 --- a/example/src/components/imessage/FireworksEffect.tsx +++ b/example/src/components/imessage/FireworksEffect.tsx @@ -13,7 +13,7 @@ type Props = { }; /** - * iMessage's Fireworks: glitter-dust shells popping over the chat. The + * Fireworks: glitter-dust shells popping over the chat. The * bursts are the birthday banner's (components/BirthdayFireworks.tsx) — * hollow polar shells of twinkling stars with an ignition flash — drawn as * a transparent overlay in front of the bubbles instead of on the banner's diff --git a/example/src/components/imessage/LasersEffect.tsx b/example/src/components/imessage/LasersEffect.tsx index 5f95f74..5a1987f 100644 --- a/example/src/components/imessage/LasersEffect.tsx +++ b/example/src/components/imessage/LasersEffect.tsx @@ -9,10 +9,9 @@ type Props = { }; /** - * iMessage's Lasers: coloured beams fan up from below the screen and sweep - * across the chat through a thin haze. Each beam is a soft line with a wide - * glow; the fan breathes rather than strobes, and the whole thing fades in - * and out so it reads as a light show, not a flicker. + * Beams: broad shafts of warm daylight fall from the top corner across the + * chat, the way sun comes through a window, drifting slowly as they fade in + * and out. */ export default function LasersEffect({ onDone }: Props) { const paramsSynchronizable = useEffectProgress(LASERS_MS, onDone); @@ -39,22 +38,14 @@ struct Uniforms { }; @group(0) @binding(0) var u: Uniforms; -const BEAMS: i32 = 8; -const CORE: f32 = 1.6; // beam half-width at full brightness, px -const GLOW: f32 = 34.0; // e-fold width of the haze around a beam, px -const SPREAD: f32 = 0.62; // half fan angle, radians -const SWEEP: f32 = 0.16; // how far each beam swings, radians -const PI: f32 = 3.14159265; +const SUN = vec3(1.0, 0.83, 0.52); +const SHAFT_SHARPNESS: f32 = 2.4; -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); -} - -// Saturated stage-light colours around the hue wheel. -fn beamColour(h: f32) -> vec3 { - return 0.55 + 0.45 * cos(6.2831853 * (h + vec3(0.0, 0.33, 0.67))); +fn shafts(angle: f32, t: f32) -> f32 { + let wide = 0.5 + 0.5 * sin(angle * 9.0 + t * 0.25); + let mid = 0.5 + 0.5 * sin(angle * 17.0 - t * 0.18 + 1.3); + let fine = 0.5 + 0.5 * sin(angle * 31.0 + t * 0.11 + 4.1); + return pow(wide * 0.55 + mid * 0.3 + fine * 0.15, SHAFT_SHARPNESS); } @fragment @@ -68,49 +59,12 @@ fn main(@location(0) ndc: vec2) -> @location(0) vec4 { let res = u.resolution.xy / u.resolution.w; let q = vec2(uv.x, 1.0 - uv.y) * res; - let env = smoothstep(0.0, 0.14, p) * (1.0 - smoothstep(0.78, 1.0, p)); - // The whole fan breathes slowly; no beam ever cuts out. - let breathe = 0.82 + 0.18 * sin(t * 4.2); - - // Beams come from a point just under the bottom edge, mid-screen. - let origin = vec2(res.x * 0.5, res.y + 30.0); - let v = q - origin; - - var rgb = vec3(0.0); - var lum = 0.0; - for (var i = 0; i < BEAMS; i = i + 1) { - let fi = f32(i); - let h = hash21(vec2(fi, 3.7)); - let g = hash21(vec2(fi, 9.1)); - // Fan spread evenly, each beam swinging on its own slow clock; the - // whole fan also leans with the play so it reads as a sweep, not a hold. - let base = -PI * 0.5 + SPREAD * ((fi + 0.5) / f32(BEAMS) * 2.0 - 1.0); - let ang = base - + SWEEP * sin(t * (0.7 + 0.5 * h) + h * 6.2831853) - + 0.22 * sin(p * PI * 2.0 + g * 3.0); - let dir = vec2(cos(ang), sin(ang)); - let along = dot(v, dir); - if (along <= 0.0) { - continue; - } - let perp = abs(v.x * dir.y - v.y * dir.x); - // Beams thin out as they travel, like light through haze. - let travel = exp(-along / (res.y * 1.6)); - let core = exp(-perp * perp / (2.0 * CORE * CORE)); - let glow = exp(-perp / GLOW) * 0.16; - let inten = (core + glow) * travel * (0.75 + 0.25 * g); - let col = beamColour(fi / f32(BEAMS) + t * 0.03); - rgb = rgb + col * inten; - lum = lum + inten; - } - - // A faint warm haze where the beams are densest, near their source. - let fog = exp(-length(v) / (res.y * 0.45)) * 0.10; - rgb = rgb + vec3(0.9, 0.7, 1.0) * fog; - lum = lum + fog; + let env = smoothstep(0.0, 0.25, p) * (1.0 - smoothstep(0.7, 1.0, p)); + let v = q - vec2(res.x * 1.1, -res.y * 0.12); + let falloff = exp(-length(v) / (res.y * 0.75)); + let inten = shafts(atan2(v.y, v.x), t + p * 6.0) * falloff * env * 1.5; - let gain = env * breathe; - let a = clamp(lum * 0.9 * gain, 0.0, 1.0); - return vec4(min(rgb * gain, vec3(1.0)), a); + let a = clamp(inten, 0.0, 1.0); + return vec4(min(SUN * inten, vec3(a)), a); } `; diff --git a/example/src/components/imessage/ScreenEffectPlayer.tsx b/example/src/components/imessage/ScreenEffectPlayer.tsx index d877708..97c2f18 100644 --- a/example/src/components/imessage/ScreenEffectPlayer.tsx +++ b/example/src/components/imessage/ScreenEffectPlayer.tsx @@ -13,7 +13,7 @@ type Props = { /** * One play of a screen effect for a bubble, as a transparent overlay in - * front of the chat, the way iMessage draws them; unmount it when `onDone` + * front of the chat, drawn above everything; unmount it when `onDone` * fires. */ export default function ScreenEffectPlayer({ effect, target, onDone }: Props) { @@ -27,15 +27,6 @@ export default function ScreenEffectPlayer({ effect, target, onDone }: Props) { case 'lasers': return ; case 'echo': - if (!target.snapshotUri) { - return null; - } - return ( - - ); + return ; } } diff --git a/example/src/components/imessage/SpotlightEffect.tsx b/example/src/components/imessage/SpotlightEffect.tsx index 6d5b056..b201fe4 100644 --- a/example/src/components/imessage/SpotlightEffect.tsx +++ b/example/src/components/imessage/SpotlightEffect.tsx @@ -12,7 +12,7 @@ type Props = { }; /** - * iMessage's Spotlight: the room goes dark and a soft beam sweeps down from + * Spotlight: the room goes dark and a soft beam sweeps down from * the top onto the new bubble, holds, and lifts. The overlay only ever * darkens, so the bubble itself is the real view seen through a hole in the * dim: nothing here redraws it. diff --git a/example/src/components/imessage/glyphs.tsx b/example/src/components/imessage/glyphs.tsx index 03b4ed8..e8e456c 100644 --- a/example/src/components/imessage/glyphs.tsx +++ b/example/src/components/imessage/glyphs.tsx @@ -1,9 +1,4 @@ -import { Image, StyleSheet, Text, View } from 'react-native'; - -// Messages' chrome glyphs. Most are drawn from plain views so they stay crisp -// at any scale without an icon font; the video camera is the SF Symbol -// "video" rendered to a white mask (assets/imessage/video.png) and tinted, -// because its flared lens does not draw well from rectangles. +import { StyleSheet, View } from 'react-native'; const WHITE = '#fff'; @@ -31,108 +26,9 @@ export function Chevron({ ); } -const VIDEO_GLYPH = require('../../../assets/imessage/video.png'); - -export function VideoCamera({ color = WHITE }: { color?: string }) { - return ( - - ); -} - -export function Plus({ color = WHITE }: { color?: string }) { - return +; -} - -export function Microphone({ color = WHITE }: { color?: string }) { - return ( - - - - - - - ); -} - -export function Lock({ color = WHITE }: { color?: string }) { - return ( - - - - - ); -} - const styles = StyleSheet.create({ chevron: { borderLeftWidth: 2.5, borderBottomWidth: 2.5, }, - camera: { - width: 26, - height: 18, - }, - plus: { - fontSize: 28, - fontWeight: '300', - lineHeight: 30, - marginTop: -1, - }, - mic: { - width: 14, - height: 22, - alignItems: 'center', - }, - micCapsule: { - width: 8, - height: 13, - borderRadius: 4, - borderWidth: 2, - }, - micArc: { - position: 'absolute', - top: 6, - width: 14, - height: 11, - borderWidth: 2, - borderTopWidth: 0, - borderBottomLeftRadius: 7, - borderBottomRightRadius: 7, - }, - micStem: { - position: 'absolute', - top: 17, - width: 2, - height: 4, - }, - micFoot: { - position: 'absolute', - top: 20, - width: 8, - height: 2, - borderRadius: 1, - }, - lock: { - width: 10, - height: 12, - alignItems: 'center', - }, - lockShackle: { - width: 7, - height: 7, - borderWidth: 1.5, - borderBottomWidth: 0, - borderTopLeftRadius: 4, - borderTopRightRadius: 4, - }, - lockBody: { - width: 10, - height: 7, - borderRadius: 2, - marginTop: -1, - }, }); diff --git a/example/src/components/imessage/types.ts b/example/src/components/imessage/types.ts index 446c655..5629868 100644 --- a/example/src/components/imessage/types.ts +++ b/example/src/components/imessage/types.ts @@ -17,11 +17,11 @@ export type ScreenEffectId = (typeof SCREEN_EFFECTS)[number]; export const EFFECT_LABEL: Record = { none: 'None', - echo: 'Echo', - spotlight: 'Spotlight', - confetti: 'Confetti', - fireworks: 'Fireworks', - lasers: 'Lasers', + echo: 'Encore', + spotlight: 'Limelight', + confetti: 'Flurry', + fireworks: 'Skyburst', + lasers: 'Beams', }; export function isScreenEffect(e: EffectId): e is ScreenEffectId { @@ -42,6 +42,4 @@ export type Rect = { x: number; y: number; w: number; h: number }; /** What a screen effect needs to know about the bubble it plays for. */ export type BubbleTarget = { rect: Rect; - /** A view-shot of the bubble, present once captured; echo needs it. */ - snapshotUri: string | null; }; diff --git a/example/src/components/instagram/SmokePoof.tsx b/example/src/components/instagram/SmokePoof.tsx index 62b143c..22a5093 100644 --- a/example/src/components/instagram/SmokePoof.tsx +++ b/example/src/components/instagram/SmokePoof.tsx @@ -14,7 +14,7 @@ type SmokePoofProps = Omit< /** * A single grey smoke puff that bursts out of the centre, billows, drifts - * up-left and thins into wisps — the "poof" Instagram plays when a voice + * up-left and thins into wisps, the "poof" that plays when a voice * message is discarded. Progress is pushed through the live channel so a * Reanimated timing curve drives it frame-accurately. */ diff --git a/example/src/components/instagram/VoiceComposer.tsx b/example/src/components/instagram/VoiceComposer.tsx index 8b4db32..76e4b84 100644 --- a/example/src/components/instagram/VoiceComposer.tsx +++ b/example/src/components/instagram/VoiceComposer.tsx @@ -23,8 +23,7 @@ import { useParamsSynchronizable } from 'react-native-effects'; import SmokePoof from './SmokePoof'; import { IG_BAR, IG_BLUE, IG_ICONS, IG_MUTED, IG_RED } from './icons'; -// Timings measured off the Instagram recording (60 fps frames). -const EXPAND_MS = 320; // mic tap → full blue bar +const EXPAND_MS = 320; // mic tap → full bar const COLLAPSE_MS = 130; // trash tap → bar sucked into the trash button const HOLD_MS = 300; // red trash button sits still before it pops const POOF_MS = 700; // smoke burst → wisps gone @@ -57,10 +56,10 @@ function Icon({ type Phase = 'idle' | 'recording' | 'deleting'; /** - * Instagram DM composer with the voice-message flow: tap the mic and a blue bar - * swells out of the camera button and starts drawing a waveform; tap the trash + * A chat composer with the voice-message flow: tap the mic and a blue bar + * swells out of the left button and starts drawing a waveform; tap the trash * and the bar is sucked back into the button, which turns red, holds, and - * bursts into a puff of smoke that clears to reveal the camera again. + * bursts into a puff of smoke that clears to reveal the button again. */ export default function VoiceComposer() { const [phase, setPhase] = useState('idle'); @@ -69,7 +68,7 @@ export default function VoiceComposer() { const [width, setWidth] = useState(0); const lastTall = useRef(false); - // 0 → 1 as the blue bar expands out of the camera button. + // 0 → 1 as the bar expands out of the left button. const rec = useSharedValue(0); // 0 → 1 as the bar collapses back into the (now red) trash button. const del = useSharedValue(0); @@ -172,7 +171,7 @@ export default function VoiceComposer() { // --- animated styles ----------------------------------------------------- - // The blue pill grows out of the camera circle, then shrinks back into it. + // The pill grows out of the left circle, then shrinks back into it. const pillStyle = useAnimatedStyle(() => { const open = rec.value * (1 - del.value); const w = interpolate(open, [0, 1], [BTN + PAD * 2, Math.max(width, 1)]); @@ -226,9 +225,9 @@ export default function VoiceComposer() { {/* Idle composer — always mounted; the pill slides over it. */} - + - Message... + Write something - - - - - - - - - {/* Recording pill */} diff --git a/example/src/components/instagram/icons.ts b/example/src/components/instagram/icons.ts index f11bbd4..1a22437 100644 --- a/example/src/components/instagram/icons.ts +++ b/example/src/components/instagram/icons.ts @@ -1,25 +1,22 @@ -// Instagram glyphs cropped from a screen recording and converted to white -// alpha masks — tint them with `tintColor`. +// White alpha masks drawn by assets/tools/generate-assets.py. Tint them with +// `tintColor`. export const IG_ICONS = { - back: require('../../../assets/instagram/back.png'), - phone: require('../../../assets/instagram/phone.png'), - video: require('../../../assets/instagram/video.png'), - share: require('../../../assets/instagram/share.png'), - bookmark: require('../../../assets/instagram/bookmark.png'), - info: require('../../../assets/instagram/info.png'), - play: require('../../../assets/instagram/play.png'), - camera: require('../../../assets/instagram/camera.png'), - mic: require('../../../assets/instagram/mic.png'), - image: require('../../../assets/instagram/image.png'), - sticker: require('../../../assets/instagram/sticker.png'), - plus: require('../../../assets/instagram/plus.png'), - trash: require('../../../assets/instagram/trash.png'), - send: require('../../../assets/instagram/send.png'), + back: require('../../../assets/chat/back.png'), + play: require('../../../assets/chat/play.png'), + mic: require('../../../assets/chat/mic.png'), + plus: require('../../../assets/chat/plus.png'), + trash: require('../../../assets/chat/trash.png'), + send: require('../../../assets/chat/send.png'), }; -export const IG_BLUE = '#4C49FA'; -export const IG_RED = '#FE5971'; -export const IG_BG = '#0E1115'; -export const IG_SURFACE = '#21242A'; -export const IG_BAR = '#16191F'; -export const IG_MUTED = '#8B8F96'; +export const IG_AVATAR = require('../../../assets/chat/avatar-rings.png'); +export const IG_CARD_AVATAR = require('../../../assets/chat/avatar-bars.png'); +export const IG_POST_PHOTO = require('../../../assets/chat/photo-post.jpg'); +export const IG_CARD_PHOTO = require('../../../assets/chat/photo-reel.jpg'); + +export const IG_BLUE = '#C98A1B'; +export const IG_RED = '#E5484D'; +export const IG_BG = '#131311'; +export const IG_SURFACE = '#23221E'; +export const IG_BAR = '#1C1B18'; +export const IG_MUTED = '#9A968B'; diff --git a/example/src/components/magic8ball/Magic8Ball.tsx b/example/src/components/magic8ball/Magic8Ball.tsx index a6eacb3..dcdd80c 100644 --- a/example/src/components/magic8ball/Magic8Ball.tsx +++ b/example/src/components/magic8ball/Magic8Ball.tsx @@ -3,35 +3,19 @@ import { ShaderView, type ParamsSynchronizable } from 'react-native-effects'; type Props = ViewProps & { /** - * `[centerX, centerY, radius, windowRadius, windowY, 0, 0, 0]`. The centre + * `[centerX, centerY, radius, revealRadius, revealY, 0, 0, 0]`. The centre * is in screen uv (y-up) and `radius` is a fraction of the screen HEIGHT; - * `windowRadius` and `windowY` are in ball radii, so the whole object keeps - * its proportions on any display. + * `revealRadius` and `revealY` are in orb radii. */ params: number[]; paramsSynchronizable: ParamsSynchronizable; }; /** - * A Magic 8-ball you shake for an answer. - * - * One opaque full-screen pass: a studio backdrop, the glossy black sphere with - * its printed 8, the moulded bezel, the domed glass, and the window — murky - * blue fluid and the answer die floating up through it. - * - * Everything shiny is lit by ONE environment (`envColor`): a rectangular - * softbox up and to the left, a wide dim fill on the right, and a studio sweep - * behind. The sphere, the bezel and the glass all reflect that same room, so - * their highlights agree — which is the difference between a photographed - * object and a pile of `pow(dot(n, h), k)` blobs. - * - * The die is drawn in the GRAVITY frame: it rises along whichever way is - * really up, so tipping the phone changes the corner of the window the answer - * arrives from. It is blurred and lost in the ink while it is deep (the - * triangle's edge softness IS the focus cue) and snaps sharp as it presses - * against the glass. The answer text itself is a React Native `Text` laid over - * the window, since a fragment shader is the wrong tool for a glyph. - * + * A clear glass orb full of pale mist, resting on a brass stand. Shaking stirs + * the mist shut. Once it settles, a clearing opens along whichever way is + * really up and the answer, a React Native `Text` laid over the shader, shows + * through it. */ export default function Magic8Ball({ params, @@ -40,7 +24,7 @@ export default function Magic8Ball({ }: Props) { return ( , time: vec4, @@ -61,6 +45,10 @@ struct Uniforms { }; @group(0) @binding(0) var u: Uniforms; +// Must match SUBMERGED_DEPTH in useMagic8BallPhysics. +const SUBMERGED_DEPTH: f32 = 1.2; +const KEY: vec3 = vec3(-0.56, 0.72, 0.41); + fn hash21(p: vec2) -> f32 { var p3 = fract(vec3(p.xyx) * 0.1031); p3 = p3 + dot(p3, p3.yzx + 33.33); @@ -78,471 +66,145 @@ fn vnoise(p: vec2) -> f32 { return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); } -/** Three octaves is enough for ink: any more and the murk turns to gravel. */ fn fbm(p0: vec2) -> f32 { var p = p0; var amp = 0.5; var acc = 0.0; - for (var i = 0; i < 3; i = i + 1) { + for (var i = 0; i < 4; i = i + 1) { acc = acc + amp * vnoise(p); - p = p * 2.07; + p = p * 2.03 + vec2(3.1, 1.7); amp = amp * 0.5; } return acc; } -/** The one key light in the room: a softbox up and to the left, in front. */ -fn keyDir() -> vec3 { - return normalize(vec3(-0.67, 0.70, 0.26)); +fn rotate(p: vec2, a: f32) -> vec2 { + let c = cos(a); + let s = sin(a); + return vec2(p.x * c - p.y * s, p.x * s + p.y * c); } -/** - * The room, as a function of direction — the only light source in this shader. - * - * A glossy black sphere has almost no diffuse of its own: what you read as its - * shape is entirely the room reflected in it. So the key is modelled as an - * actual RECTANGLE, not a phong lobe. A softbox has straight edges and a hard - * corner, and that straight-edged reflection sliding over a curve is most of - * what says "photographed plastic". - * - * \`rough\` widens the box edges and dims it to match, so the same room can be - * sampled by a mirror sphere, a satin bezel and a glass dome and still look - * like one room at three gloss levels. - */ -fn envColor(d: vec3, rough: f32) -> vec3 { - // The sweep behind: dim and cool up high, falling to near black at the floor. - var c = mix(vec3(0.013, 0.015, 0.021), - vec3(0.085, 0.100, 0.140), - smoothstep(-0.55, 0.85, d.y)); - c = mix(vec3(0.006, 0.006, 0.009), c, smoothstep(-0.95, -0.30, d.y)); +fn backdrop(p: vec2) -> vec3 { + let pool = exp(-dot(p - vec2(0.0, 0.5), p - vec2(0.0, 0.5)) / 7.0); + return mix(vec3(0.008, 0.014, 0.016), vec3(0.060, 0.115, 0.120), pool); +} - let k = keyDir(); +// A paned window high on the left and a dim fill on the right: all the glass +// ever reflects, so every highlight on the orb agrees. +fn room(d: vec3) -> vec3 { + var c = mix(vec3(0.01, 0.015, 0.018), vec3(0.10, 0.15, 0.16), + smoothstep(-0.6, 0.9, d.y)); + let k = normalize(KEY); let tx = normalize(cross(k, vec3(0.0, 1.0, 0.0))); let ty = cross(tx, k); - let ck = dot(d, k); - if (ck > 0.02) { - // Project onto the plane facing the light, then a soft-edged rectangle. - let pd = d - k * ck; - let ax = abs(dot(pd, tx)); - let ay = abs(dot(pd, ty)); - // A rounded box, not two crossed smoothsteps: a softbox has a diffuser - // and a frame, so its corners are round, and a sphere stretches whatever - // shape you give it. Square corners come back as a strip of tape. - let soft = 0.026 + rough * 0.55; - let crn = 0.075; - let bd = vec2(ax, ay) - vec2(0.125, 0.195) + crn; - let sdf = length(max(bd, vec2(0.0))) - + min(max(bd.x, bd.y), 0.0) - crn; - let box = 1.0 - smoothstep(-soft, soft, sdf); - // A real softbox is orders of magnitude over mid grey, which is why its - // reflection clips to white through a 4% Fresnel. Dim with roughness so - // spreading it over a wider lobe does not also brighten it. - c = c + vec3(1.0, 0.98, 0.95) * box * (26.0 / (1.0 + rough * 7.0)); + let facing = dot(d, k); + if (facing > 0.0) { + let w = vec2(dot(d, tx), dot(d, ty)); + let frame = 1.0 - smoothstep(0.0, 0.02, max(abs(w.x) - 0.15, abs(w.y) - 0.21)); + let mullion = smoothstep(0.004, 0.012, abs(w.x)) + * smoothstep(0.004, 0.012, abs(w.y)); + c = c + vec3(1.0, 0.97, 0.9) * frame * mullion * 9.0; } - - // A broad, dim fill panel on the other side, so the shadow half of anything - // round keeps some shape instead of going flat black. - let fl = normalize(vec3(0.80, -0.10, 0.58)); - let cf = max(dot(d, fl), 0.0); - c = c + vec3(0.20, 0.25, 0.36) * cf * cf * cf * 0.55; - - // Two wall washes BEHIND the ball. A sphere mirrors everything in front of - // it into the inner half of its disc, which on this object is covered by the - // window — so the only thing that can shape the black ring around the bezel - // is light from behind. These two draw the crescents down its silhouette. - let bkl = normalize(vec3(-0.66, 0.46, -0.60)); - let cl = max(dot(d, bkl), 0.0); - c = c + vec3(0.62, 0.70, 0.92) * cl * cl * cl * cl * 1.75; - let bkr = normalize(vec3(0.78, 0.10, -0.62)); - let cr = max(dot(d, bkr), 0.0); - c = c + vec3(0.30, 0.35, 0.48) * cr * cr * cr * cr * cr * cr * 0.85; - return c; -} - -/** Schlick, for a dielectric. \`ct\` is cos(view, normal). */ -fn fres(ct: f32) -> f32 { - let m = clamp(1.0 - ct, 0.0, 1.0); - let m2 = m * m; - return 0.04 + 0.96 * m2 * m2 * m; -} - -/** Mirror the view direction (0,0,1) about a normal. */ -fn mirror(n: vec3) -> vec3 { - return 2.0 * n.z * n - vec3(0.0, 0.0, 1.0); + let fill = max(dot(d, normalize(vec3(0.8, -0.2, 0.55))), 0.0); + return c + vec3(0.3, 0.42, 0.42) * fill * fill * fill * 0.5; } -/** Circle outline, signed. Positive outside the stroke. */ -fn ringField(q: vec2, rad: f32, th: f32) -> f32 { - return abs(length(q) - rad) - th; +fn fresnel(cosine: f32) -> f32 { + let m = clamp(1.0 - cosine, 0.0, 1.0); + return 0.04 + 0.96 * m * m * m * m * m; } -/** - * Signed distance to the line through p and q, positive on the side away from - * the interior point g. The building block for every part of the die. - */ -fn edgeHp(x: vec2, p: vec2, q: vec2, g: vec2) -> f32 { - let e = q - p; - var n = normalize(vec2(e.y, -e.x)); - if (dot(p - g, n) < 0.0) { - n = -n; - } - return dot(x - p, n); -} - -/** Distance from x to the segment a-b. */ -fn segD(x: vec2, a: vec2, b: vec2) -> f32 { - let xa = x - a; - let ba = b - a; - let h = clamp(dot(xa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0); - return length(xa - ba * h); +fn roundBox(p: vec2, half: vec2, r: f32) -> f32 { + let d = abs(p) - half + r; + return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r; } -/** - * True signed distance to convex triangle a-b-c. Negative inside. - * - * A max of the three edge half-planes would be cheaper and is exact inside — - * but outside a corner it reads the distance to the nearer EDGE LINE, not to - * the corner, so subtracting a radius from it mitres the corner instead of - * rounding it. Rounded corners are the whole reason this is here. - */ -fn sdTri(x: vec2, a: vec2, b: vec2, c: vec2) -> f32 { - let g = (a + b + c) / 3.0; - let d = min(min(segD(x, a, b), segD(x, b, c)), segD(x, c, a)); - var s = edgeHp(x, a, b, g); - s = max(s, edgeHp(x, b, c, g)); - s = max(s, edgeHp(x, c, a, g)); - return select(d, -d, s < 0.0); +fn brass(x: f32, y: f32) -> vec3 { + let sheen = pow(max(cos(x * 2.6 + 0.7), 0.0), 3.0); + let shade = mix(vec3(0.10, 0.065, 0.025), vec3(0.62, 0.45, 0.19), sheen); + return shade * (0.7 + 0.5 * smoothstep(-0.12, 0.12, y)); } -/** True signed distance to convex quad a-b-c-d, wound in order. */ -fn sdQuad(x: vec2, a: vec2, b: vec2, - c: vec2, d: vec2, g: vec2) -> f32 { - let dd = min(min(segD(x, a, b), segD(x, b, c)), - min(segD(x, c, d), segD(x, d, a))); - var s = edgeHp(x, a, b, g); - s = max(s, edgeHp(x, b, c, g)); - s = max(s, edgeHp(x, c, d, g)); - s = max(s, edgeHp(x, d, a, g)); - return select(dd, -dd, s < 0.0); +fn drawStand(col0: vec3, p: vec2, e: f32) -> vec3 { + var col = col0; + let footP = p - vec2(0.0, -1.1); + let foot = roundBox(footP, vec2(0.5, 0.07), 0.05); + col = mix(col, brass(footP.x, footP.y), 1.0 - smoothstep(-e, e, foot)); + let collarP = p - vec2(0.0, -0.96); + let collar = roundBox(collarP, vec2(0.36, 0.12), 0.04); + return mix(col, brass(collarP.x, collarP.y) * 0.85, 1.0 - smoothstep(-e, e, collar)); } -/** - * A neighbouring face of the die, hinged back along the edge p-q. - * - * It is a TRAPEZOID, not a triangle: the shared edge is its long side, and - * because the face is tipped away from the glass its far side lands shorter - * and closer than a flat triangle's would. That is the shape the real object - * shows on all three sides of the answer, and it is the whole reason the die - * reads as a solid twenty-sider — a triangle behind a triangle reads as a - * hexagram, which is the thing this replaced. - * - * \`reach\` is how far it leans out, \`taper\` how much of p-q its far side keeps. - */ -fn flapQuad(x: vec2, p: vec2, q: vec2, g: vec2, - reach: f32, taper: f32) -> f32 { - let m = (p + q) * 0.5; - let e = q - p; - var n = normalize(vec2(e.y, -e.x)); - if (dot(m - g, n) < 0.0) { - n = -n; - } - let p2 = m + (p - m) * taper + n * reach; - let q2 = m + (q - m) * taper + n * reach; - // Halfway out along the middle is inside the trapezoid for any taper > 0. - return sdQuad(x, p, q, q2, p2, m + n * reach * 0.5); +fn mistField(q: vec2, t: f32, churn: f32) -> f32 { + let w = vec2(fbm(q * 1.5 + vec2(1.7, t * 0.05)), + fbm(q * 1.5 + vec2(t * 0.04, 9.2))) - 0.5; + return fbm(q * 2.1 + w * (1.2 + 2.2 * churn)); } @fragment fn main(@location(0) ndc: vec2) -> @location(0) vec4 { let aspect = u.resolution.z; let uv = ndc * 0.5 + 0.5; - let ctr = u.params0.xy; let rad = max(u.params0.z, 0.001); - let wr = max(u.params0.w, 0.001); - let wy = u.params1.x; - - // Ball space: origin at the sphere's centre, ONE unit = its radius. - let p = vec2((uv.x - ctr.x) * aspect, uv.y - ctr.y) / rad; + let p = vec2((uv.x - u.params0.x) * aspect, uv.y - u.params0.y) / rad; let r = length(p); - // One pixel, in ball units — every edge below is antialiased with it. let e = 1.5 / (u.resolution.y * rad); + let t = u.time.x; let tilt = u.live.x; let churn = clamp(u.live.y, 0.0, 1.0); let rise = clamp(u.live.z, 0.0, 1.0); let swirl = u.live.w; let sway = u.liveData[0].x; - let turn = u.liveData[0].y; - - let key = keyDir(); - - // ---- The backdrop: a studio sweep, and the ball's shadow on the floor. ---- - // The sweep is the same room the ball reflects, seen directly: brightest - // just behind and above it, falling off to black at the frame. - let bgSweep = exp(-pow((p.y - 0.35) / 2.3, 2.0)) - * exp(-pow(p.x / 2.6, 2.0)); - var col = mix(vec3(0.009, 0.010, 0.014), - vec3(0.058, 0.063, 0.079), bgSweep); - // Contact shadow. Two lobes: a wide soft pool thrown away from the key, and - // a tight dark core right where the ball meets the floor. The core is what - // makes it sit on something instead of hovering over a smudge. - let sh = p - vec2(-key.x * 0.55, -1.0); - let pool = exp(-dot(vec2(sh.x / 1.45, sh.y / 0.30), - vec2(sh.x / 1.45, sh.y / 0.30))); - let core = exp(-dot(vec2(p.x / 0.62, (p.y + 1.01) / 0.10), - vec2(p.x / 0.62, (p.y + 1.01) / 0.10))); - col = col * (1.0 - clamp(pool * 0.7 + core * 0.85, 0.0, 0.97)); + var col = backdrop(p); + let shadow = exp(-pow(p.x / 1.5, 2.0) - pow((p.y + 1.2) / 0.16, 2.0)); + col = col * (1.0 - 0.8 * shadow); + col = drawStand(col, p, e); - let bm = 1.0 - smoothstep(-e, e, r - 1.0); - if (bm > 0.001) { - // ---- Glossy black plastic. ---- + let mask = 1.0 - smoothstep(-e, e, r - 1.0); + if (mask > 0.001) { let rc = min(r, 1.0); let nz = sqrt(max(1.0 - rc * rc, 0.0)); - let n = normalize(vec3(p.x, p.y, max(nz, 0.02))); - let ndl = max(dot(n, key), 0.0); - - // Black plastic is almost all reflection: a near-black body, the room - // mirrored in it, weighted by Fresnel. No hand-placed rim line and no - // additive Fresnel glow — the horizon of the reflected room lights the - // silhouette on its own, and it does it in the right places. - var bc = vec3(0.021, 0.022, 0.027); - bc = bc + vec3(0.030, 0.032, 0.040) * ndl; - let m = mirror(n); - bc = bc + (envColor(m, 0.022) * 0.72 + envColor(m, 0.34) * 0.42) - * fres(n.z); - // Bounce off the floor into the shaded underside. - bc = bc + vec3(0.030, 0.031, 0.036) * smoothstep(-0.45, -1.0, p.y); - - // ---- The white 8, printed on the pole turned away from us. ---- - // Done on the SPHERE, not as a squashed ellipse in screen space: the decal - // is a cap of directions around a pole, and its coordinates are the - // normal's components in that pole's tangent frame. Foreshortening then - // falls out of the geometry, including the way the glyph leans. - let pole = normalize(vec3(-0.045, 0.720, 0.693)); - let gt = normalize(cross(pole, vec3(0.0, 0.0, 1.0))); - let gb = cross(pole, gt); - let capCos = 0.9703; - let capSin = sqrt(1.0 - capCos * capCos); - let gp = vec2(dot(n, gt), dot(n, gb)) / capSin; - // Edge softness in decal units: one pixel of p, opened up by the grazing - // angle, over the cap's radius. - let ge = e / (capSin * max(n.z, 0.18)); - let disc = smoothstep(-ge, ge, 1.0 - length(gp)) * step(0.0, dot(n, pole)); - if (disc > 0.001) { - // The decal is printed, not emissive: same key, same room, just a white - // body under it — and a touch less gloss than the surrounding plastic. - var dc = vec3(0.78, 0.785, 0.80) * (0.26 + 0.74 * ndl); - dc = dc + envColor(mirror(n), 0.16) * fres(n.z) * 0.55; - bc = mix(bc, dc, disc); - let top = ringField(gp - vec2(0.0, 0.255), 0.215, 0.080); - let bot = ringField(gp - vec2(0.0, -0.275), 0.265, 0.088); - let glyph = smoothstep(-ge, ge, -min(top, bot)) * disc; - bc = mix(bc, vec3(0.055, 0.055, 0.065) * (0.4 + 0.6 * ndl), glyph); - } - - // ---- The moulded bezel and the window sunk into it. ---- - let wp = p - vec2(0.0, wy); - let wd = length(wp); - let wdir = wp / max(wd, 1e-4); - let outer = wr * 1.125; - - let insert = 1.0 - smoothstep(-e, e, wd - outer); - if (insert > 0.001 && wd > wr * 0.999) { - // A bead, not an annulus: the ring rolls up out of the black plastic, - // crests, and rolls back down into the window. Tilting the normal along - // that profile gives it a highlight arc that tracks the key, which is - // the whole difference between a moulded part and a pasted-on disc. - let t = clamp((wd - wr) / (outer - wr), 0.0, 1.0); - let slope = cos(t * 3.14159265) * 0.62; - let nb = normalize(n + vec3(wdir * slope, 0.0)); - let bl = max(dot(nb, key), 0.0); - var ic = vec3(0.905, 0.910, 0.925) * (0.30 + 0.70 * bl); - // The lower half of the rim sees no key at all — only the floor bounce. - // Without this it lights up as brightly as the top and the ring flattens. - ic = ic * (0.56 + 0.44 * smoothstep(-0.85, 0.35, - dot(wdir, vec2(key.x, key.y)))); - ic = ic + envColor(mirror(nb), 0.46) * fres(nb.z) * 0.55; - // Occlusion in the two creases: against the black plastic outside, and - // down the throat into the window. - ic = ic * (0.55 + 0.45 * smoothstep(0.0, 0.14, t)); - ic = ic * (0.62 + 0.38 * smoothstep(1.0, 0.80, t)); - bc = mix(bc, ic, insert); - } - - let wm = 1.0 - smoothstep(-e, e, wd - wr); - if (wm > 0.001) { - // Window space, normalized so the rim is at |q| = 1. - let q = wp / wr; - let wdn = min(length(q), 1.0); - - // The cover is a shallow dome, so its normal bends outward toward the - // rim: that bend both bulges the fluid behind it and carries the room's - // reflection across the glass. - let ng = normalize(n + vec3(wdir * pow(wdn, 2.2) * 0.62, 0.0)); - // Refraction through that dome: sampling nearer the centre at the rim - // magnifies what is behind it, the way a watch crystal does. - let qr = q * (1.0 - 0.12 * wdn * wdn); - - // Gravity frame: dn points downhill, ax runs across it. - let dn = vec2(sin(tilt), -cos(tilt)); - let up = -dn; - let across = vec2(-up.y, up.x); - let al = dot(qr, up); - - // ---- Murky fluid: fbm, domain-warped, swirling harder when churned. ---- - let w1 = fbm(qr * 1.9 + vec2(swirl * 0.13, 1.7)) - 0.5; - let w2 = fbm(qr * 1.9 + vec2(5.2, swirl * 0.11 + 9.1)) - 0.5; - let qq = qr + vec2(w1, w2) * (0.15 + 0.4 * churn); - let ink = fbm(qq * 2.7 + vec2(0.0, swirl * 0.07)); - var mc = mix(vec3(0.003, 0.005, 0.017), - vec3(0.018, 0.028, 0.080), ink); - // The mix alone is too even to read as moving fluid; a steep power of - // the same field picks out tendrils where the ink has piled up. Gated on - // churn, because agitated fluid is what catches the light — at rest this - // has to stay near black or the die has nothing to read against. - mc = mc + vec3(0.07, 0.10, 0.24) - * pow(clamp(ink, 0.0, 1.0), 2.4) * (0.10 + 0.90 * churn); - // The key reaches into the fluid from its own side, and the ink pools - // downhill away from it. - mc = mc * (0.72 + 0.55 * clamp(dot(qr, vec2(key.x, key.y)) * -0.5 - + 0.5 - dot(qr, qr) * 0.35, 0.0, 1.0)); - mc = mc * (0.9 + 0.2 * smoothstep(1.0, -1.0, al)); - - // ---- The answer die, floating up the world-up axis. ---- - // How far below the window the die sits when it is lost in the murk. - // Must match SUBMERGED_DEPTH in useMagic8BallPhysics, which places the - // answer text on the same die. Far enough that the answer RISES INTO the - // window: parked inside it, the die can only fade up in place, and a - // fade is what makes the whole thing look like an animation. - let depth = 1.2 * (1.0 - rise); - let fc = up * -depth + across * sway * 0.1; - let scl = mix(0.71, 0.84, rise); - let fq0 = (qr - fc) / scl; - // The die is a free object in a fluid, so its ORIENTATION belongs to the - // world, not to the phone: its top edge stays level however the phone is - // held, and a die that comes up already square to the window is the - // giveaway, so it also arrives turned and rotates into place as it - // settles. Both are one angle, summed on the JS side. Rotating the - // SAMPLE point by -turn turns the shape by +turn. - let cw = cos(turn); - let sw = sin(turn); - let fq = vec2(fq0.x * cw + fq0.y * sw, -fq0.x * sw + fq0.y * cw); - // The edge softness IS the focus cue: metres of ink in front of it when - // it is deep, pressed against the glass when it is up. - // Deliberately not linear in rise: the die has to stay lost in the ink - // through the first half of the climb and only pull into focus as it - // comes up against the glass. Softness fading evenly with height reads - // as a cross-fade, which is the thing this is trying not to look like. - let te = mix(0.42, 0.014, smoothstep(0.22, 1.0, rise)); - - // The answer face: an EQUILATERAL triangle with its three corners cut - // flat. Both halves of that matter. The real die's face is a regular - // triangle, not the squat one this started with; and its corners end in - // a short straight edge of their own, so the outline is really a hexagon - // with three long sides and three short ones — neither the sharp points - // it had nor a rounded-off version of them. - // - // Cutting all three corners of an equilateral triangle by the same - // amount is exactly intersecting it with its own 180-degree rotation, - // scaled up: the inverted triangle's three edges face the original's - // three corners and slice them off square. So the face is one max() of - // two triangles rather than a six-sided polygon test. - let ea = vec2(-0.8000, 0.4400); - let eb = vec2(0.8000, 0.4400); - let ec = vec2(0.0, -0.9456); - let ia = vec2(1.4501, -0.8593); - let ib = vec2(-1.4501, -0.8593); - let ic = vec2(0.0, 1.6528); - let fg = vec2(0.0, -0.0219); - // Where the cut corners leave the three LONG sides, which is what the - // hinged faces attach to. - let h1 = vec2(-0.7000, 0.4400); - let h2 = vec2(0.7000, 0.4400); - let h3 = vec2(0.7500, 0.3534); - let h4 = vec2(0.0500, -0.8590); - let h5 = vec2(-0.0500, -0.8590); - let h6 = vec2(-0.7500, 0.3534); - - // The three faces hinged back off its edges — a trapezoid on every side, - // which is what the real object shows and what makes this a solid rather - // than a triangle floating in ink. - // Tight against the edges on purpose. Hinged faces that reach far - // enough to stick out past the corners stop reading as the body behind - // the answer and start reading as facets of a cut gem — the silhouette - // has to stay a triangle, with these just thickening its outline. - let flapSd = min(min(flapQuad(fq, h1, h2, fg, 0.085, 0.70), - flapQuad(fq, h3, h4, fg, 0.080, 0.68)), - flapQuad(fq, h5, h6, fg, 0.080, 0.68)); - // Softer than the front face however close the die gets: these are - // hinged back behind it, so they never reach the glass and never come - // fully into focus. - let fe = te * 1.25 + 0.010; - let sm = (1.0 - smoothstep(-fe, fe, flapSd)) - * smoothstep(0.18, 0.72, rise); - if (sm > 0.002) { - // Tipped away from both the key and the glass, so a fraction of the - // face's blue — but nowhere near the ink, or the body stops reading. - let bodyLit = 0.66 + 0.34 * smoothstep(-0.8, 0.9, fq.y); - mc = mix(mc, vec3(0.034, 0.060, 0.205) * bodyLit, sm); - } - - let td = max(sdTri(fq, ea, eb, ec), sdTri(fq, ia, ib, ic)); - // Dye that bright in fluid that dark spills a little light into the ink - // around it. Without it the die looks pasted onto the window rather than - // suspended behind it. - mc = mc + vec3(0.05, 0.10, 0.30) - * exp(-max(min(td, flapSd), 0.0) * 21.0) - * smoothstep(0.15, 0.8, rise) * 0.38; - - let tm = 1.0 - smoothstep(-te, te, td); - if (tm > 0.002) { - // Lit from the key like everything else: the face is brightest at the - // top-left corner the light comes from and falls off toward the apex. - let lit = clamp(0.5 - dot(normalize(vec2(fq.x, fq.y - 0.1) - + vec2(0.001, 0.0)), - normalize(vec2(key.x, key.y))) * 0.5, - 0.0, 1.0); - var fcol = mix(vec3(0.095, 0.185, 0.660), - vec3(0.150, 0.300, 0.930), - (0.34 + 0.66 * smoothstep(0.5, -1.0, fq.y)) - * (0.55 + 0.45 * lit)); - // The moulded bevel around the face, brighter on the edges turned - // toward the key and darker on the ones turned away. - let bev = 1.0 - smoothstep(0.0, 0.045, abs(td)); - let edgeLit = clamp(dot(normalize(vec2(fq.x, fq.y + 0.18) - + vec2(0.0, 0.001)), - normalize(vec2(key.x, key.y))), -1.0, 1.0); - fcol = fcol + vec3(0.30, 0.42, 0.70) * bev - * (0.08 + 0.32 * max(edgeLit, 0.0)); - fcol = fcol * (1.0 - bev * 0.35 * max(-edgeLit, 0.0)); - // Ink in front of it while it is still deep. - fcol = mix(mc * 0.30, fcol, smoothstep(0.05, 0.78, rise)); - mc = mix(mc, fcol, tm * mix(0.78, 1.0, rise)); - } - - // ---- The glass over the window. ---- - // The bezel throws a shadow down the inside of the rim, heaviest on the - // side away from the key; that shadow is what seats the window INTO the - // ball instead of on it. - let rimShade = smoothstep(0.55, 1.0, wdn); - let awayFromKey = clamp(0.5 - dot(q, vec2(key.x, key.y)) * 0.6, - 0.0, 1.0); - mc = mc * (1.0 - rimShade * (0.28 + 0.52 * awayFromKey)); - // Glass is thicker at the rim, so it drinks more of what is behind it. - mc = mc * (1.0 - smoothstep(0.80, 1.0, wdn) * 0.35); - // And the room, reflected off the dome — the same softbox that lit the - // bezel, continuing across the glass. - mc = mc + envColor(mirror(ng), 0.30) * fres(ng.z) * 0.40; - - bc = mix(bc, mc, wm); - } - - col = mix(col, bc, bm); + let n = normalize(vec3(p, max(nz, 0.02))); + + // Glass magnifies toward the rim, so the mist bulges with the orb. + let lens = p * (1.0 + 0.35 * (1.0 - nz)); + let q = rotate(lens, (swirl * 0.14 + t * 0.03) * (1.4 - rc)); + let plume = mistField(q, t, churn); + let wisps = fbm(q * 4.6 + vec2(plume * 3.0, t * 0.03)); + + // The clearing rises along world up and carries the answer with it. + let revealR = max(u.params0.w, 0.001); + let up = vec2(-sin(tilt), cos(tilt)); + let across = vec2(-up.y, up.x); + let center = vec2(0.0, u.params1.x) + + (up * -SUBMERGED_DEPTH * (1.0 - rise) + across * sway * 0.1) * revealR; + let opening = smoothstep(0.3, 1.0, rise) * (1.0 - churn); + let holeR = revealR * 1.12 * opening; + let holeEdge = length(p - center) + (wisps - 0.5) * 0.5; + let clearing = (1.0 - smoothstep(holeR - 0.26, holeR + 0.08, holeEdge)) * opening; + + let density = clamp(0.5 + smoothstep(0.26, 0.62, plume) * 0.5, 0.0, 1.0); + let lit = clamp(0.48 + dot(q, KEY.xy) * 0.42 + (plume - 0.45) * 0.9, 0.0, 1.0); + let mist = mix(vec3(0.20, 0.31, 0.33), vec3(0.88, 0.93, 0.90), lit * lit) + * (0.92 + 0.16 * wisps); + + let hollow = backdrop(-p * 1.4) * 0.8 + vec3(0.012, 0.03, 0.034); + let glow = exp(-dot(p - center, p - center) / (0.34 + revealR * 0.5)); + let core = hollow + vec3(0.40, 0.33, 0.14) * glow * opening * 0.5; + var body = mix(core, mist, density * (1.0 - clearing)); + + body = body * mix(1.0, 0.55, smoothstep(0.72, 1.0, rc)); + let caustic = exp(-pow((rc - 0.86) / 0.08, 2.0)) * smoothstep(-0.2, -0.9, n.y); + body = body + vec3(0.75, 0.82, 0.72) * caustic * 0.32; + + let reflected = 2.0 * n.z * n - vec3(0.0, 0.0, 1.0); + body = body + room(reflected) * (fresnel(n.z) + 0.05); + col = mix(col, body, mask); } - // ---- Dither, so the near-black backdrop does not band. ---- - let dth = (hash21(uv * u.resolution.xy) - 0.5) * (2.0 / 255.0); - return vec4(clamp(col + vec3(dth), vec3(0.0), vec3(1.0)), 1.0); + let dither = (hash21(uv * u.resolution.xy) - 0.5) * (2.0 / 255.0); + return vec4(clamp(col + vec3(dither), vec3(0.0), vec3(1.0)), 1.0); } `; diff --git a/example/src/components/namedrop/ContactPoster.tsx b/example/src/components/namedrop/ContactPoster.tsx deleted file mode 100644 index 250ac07..0000000 --- a/example/src/components/namedrop/ContactPoster.tsx +++ /dev/null @@ -1,228 +0,0 @@ -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/DuskPoster.tsx b/example/src/components/namedrop/DuskPoster.tsx new file mode 100644 index 0000000..d553942 --- /dev/null +++ b/example/src/components/namedrop/DuskPoster.tsx @@ -0,0 +1,58 @@ +import { forwardRef, type ComponentRef } from 'react'; +import { Image, StyleSheet, Text, View } from 'react-native'; +import { SERIF } from '../../catalog/theme'; + +/** Drawn from `cracks/swing.jpg` by assets/tools/generate-assets.py. */ +const DUSK = require('../../../assets/wave/dusk.jpg'); + +export type PosterHandle = ComponentRef; + +type DuskPosterProps = { + width: number; + height: number; + onImageLoad?: () => void; +}; + +/** The full-screen dusk scene the wave reveals. */ +const DuskPoster = forwardRef( + function DuskPoster({ width, height, onImageLoad }, ref) { + return ( + + + + Dusk + Tap to replay the wave + + + ); + } +); + +export default DuskPoster; + +const styles = StyleSheet.create({ + caption: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + }, + title: { + color: '#fff', + fontFamily: SERIF, + fontSize: 64, + lineHeight: 72, + fontWeight: '600', + }, + hint: { + marginTop: 8, + color: '#fff', + fontSize: 16, + fontWeight: '500', + }, +}); diff --git a/example/src/components/namedrop/NameDropWave.tsx b/example/src/components/namedrop/NameDropWave.tsx index 5b95f7d..ccc3e08 100644 --- a/example/src/components/namedrop/NameDropWave.tsx +++ b/example/src/components/namedrop/NameDropWave.tsx @@ -5,26 +5,25 @@ import { } from 'react-native-effects'; /** - * The home screen photo next to two pre-blurred copies of itself, so the + * The misty morning scene 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 SCENE_ATLAS = require('../../../assets/wave/scene-atlas.jpg'); +/** The cut-out painted into the scene, 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. */ + /** `u.live.x` = seconds since the wave started. */ 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. + * A coloured band of light climbs from the bottom edge and pools under the + * cut-out, blurring what it crosses, then a bright liquid wave bursts out of + * it and the screen sinks into a dark blur behind the incoming poster. */ export default function NameDropWave({ paramsSynchronizable, @@ -35,7 +34,7 @@ export default function NameDropWave({ fragmentShader={SHADER} paramsSynchronizable={paramsSynchronizable} params={[ISLAND_UV.x, ISLAND_UV.y, ISLAND_UV.w, ISLAND_UV.h]} - texture={HOME_ATLAS} + texture={SCENE_ATLAS} {...rest} /> ); diff --git a/example/src/components/namedrop/PosterReveal.tsx b/example/src/components/namedrop/PosterReveal.tsx index a1c132b..3f5b95c 100644 --- a/example/src/components/namedrop/PosterReveal.tsx +++ b/example/src/components/namedrop/PosterReveal.tsx @@ -9,15 +9,15 @@ type PosterRevealProps = Omit< ShaderViewProps, 'fragmentShader' | 'paramsSynchronizable' | 'transparent' | 'params' > & { - /** `u.live.x` = seconds since the phones touched. */ + /** `u.live.x` = seconds since the wave started. */ paramsSynchronizable: ParamsSynchronizable; - /** A full-screen snapshot of the contact poster. */ + /** A full-screen snapshot of the 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 + * The poster arriving: a blurred card swells out of the top of the screen on + * one critically damped spring, coming into focus as it grows to * fill the display. */ export default function PosterReveal({ diff --git a/example/src/components/polaroid/PolaroidCamera.tsx b/example/src/components/polaroid/PolaroidCamera.tsx index c59a64a..90fecfa 100644 --- a/example/src/components/polaroid/PolaroidCamera.tsx +++ b/example/src/components/polaroid/PolaroidCamera.tsx @@ -37,8 +37,7 @@ function loadExpoCamera(): ExpoCamera | null { const expoCamera = loadExpoCamera(); -/** The classic stripe, top to bottom. */ -const STRIPE = ['#e2402c', '#f08a24', '#f6c73a', '#4aa564', '#2f7fc1']; +const STRIPE = ['#d8d4cb', '#8b8880']; const WINDOW_SIZE = 164; export const CAMERA_BODY_HEIGHT = 216; @@ -56,7 +55,7 @@ function CameraPrompt({ style={styles.prompt} > - {canAsk ? 'Use camera' : 'Open Settings'} + {canAsk ? 'Continue' : 'Open Settings'} ); diff --git a/example/src/components/polaroid/PolaroidPrint.tsx b/example/src/components/polaroid/PolaroidPrint.tsx index d74a420..f41701c 100644 --- a/example/src/components/polaroid/PolaroidPrint.tsx +++ b/example/src/components/polaroid/PolaroidPrint.tsx @@ -18,7 +18,7 @@ type PolaroidPrintProps = Omit< }; /** - * A Polaroid print developing. + * An instant 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 diff --git a/example/src/components/scratchoff/ScratchCoating.tsx b/example/src/components/scratchoff/ScratchCoating.tsx index ae17d5f..1a7f694 100644 --- a/example/src/components/scratchoff/ScratchCoating.tsx +++ b/example/src/components/scratchoff/ScratchCoating.tsx @@ -5,14 +5,13 @@ import { type ShaderViewProps, } from 'react-native-effects'; -/** The play area, in points. Three prize boxes in a row, a bonus strip. */ +/** The play area, in points. Three prize boxes in a row. */ export const PLAY_W = 340; -export const PLAY_H = 182; +export const PLAY_H = 104; 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; @@ -35,7 +34,7 @@ type ScratchCoatingProps = Omit< */ paramsSynchronizable: ParamsSynchronizable; /** Symbol under each area: star, clover, diamond, heart. */ - symbols: readonly [PrizeSymbol, PrizeSymbol, PrizeSymbol, PrizeSymbol]; + symbols: readonly [PrizeSymbol, PrizeSymbol, PrizeSymbol]; }; /** @@ -201,8 +200,7 @@ 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)}; } + default: { return ${rect(2)}; } } } @@ -211,12 +209,8 @@ fn symbolKind(i: i32) -> f32 { } 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 c = r.xy + r.zw * 0.5; + let 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); @@ -261,7 +255,7 @@ fn main(@location(0) ndc: vec2) -> @location(0) vec4 { var area = -1; var r = vec4(0.0); - for (var i = 0; i < 4; i++) { + for (var i = 0; i < 3; i++) { let ri = areaRect(i); if (sdRoundRect(pt, ri, RADIUS) < 0.0) { area = i; diff --git a/example/src/components/telegram/ParticleDissolve.tsx b/example/src/components/telegram/ParticleDissolve.tsx index 6004374..ddea7f3 100644 --- a/example/src/components/telegram/ParticleDissolve.tsx +++ b/example/src/components/telegram/ParticleDissolve.tsx @@ -19,7 +19,7 @@ type ParticleDissolveProps = Omit< }; /** - * Telegram-style "message dissolve": the snapshot of a view is cut into a grid + * "Message dissolve": the snapshot of a view is cut into a grid * of ~1.5pt cells, and a sweep running left→right turns each cell into a * particle that drifts up-left on a smooth wind field, jitters, twinkles and * fades. Cells the sweep has not reached yet are drawn from the snapshot @@ -79,7 +79,7 @@ const GATHER: i32 = 3; // neighbourhood half-width in cells const SPREAD: f32 = 2.4; // per-particle random offset, logical px (< GATHER*CELL) const DRIFT: f32 = 140.0; // wind travel over a life, logical px const BODY_DROP: f32 = 0.72; // share of bubble-body cells that never become dust -const RADIUS: f32 = 17.0; // bubble corner radius, logical px +const RADIUS: f32 = 13.0; // bubble corner radius, logical px fn hash21(p: vec2) -> f32 { var p3 = fract(vec3(p.xyx) * 0.1031); diff --git a/example/src/components/telegram/SpoilerParticles.tsx b/example/src/components/telegram/SpoilerParticles.tsx index 3c0136a..24c93ab 100644 --- a/example/src/components/telegram/SpoilerParticles.tsx +++ b/example/src/components/telegram/SpoilerParticles.tsx @@ -19,12 +19,12 @@ type SpoilerParticlesProps = Omit< * its alpha is the exact per-line band the cloud has to cover. */ maskRect: SpoilerRect; - /** Particle colour; near-white on Telegram's dark theme. */ + /** Particle colour. */ color?: ColorInput; }; /** - * Telegram's hidden-text cloud: a dense field of tiny sparks drifting over the + * The hidden-text cloud: a dense field of tiny sparks drifting over the * lines of a spoiler span, blown outward from the tap when it is revealed. * * One particle per ~1.9 pt cell; each wanders on two slow sines and pulses in diff --git a/example/src/components/telegram/icons.ts b/example/src/components/telegram/icons.ts index bb55035..2aa06d5 100644 --- a/example/src/components/telegram/icons.ts +++ b/example/src/components/telegram/icons.ts @@ -1,33 +1,19 @@ -// Telegram glyphs cropped from a screen recording and converted to white -// alpha masks — tint them with `tintColor`. +// White alpha masks drawn by assets/tools/generate-assets.py. Tint them with +// `tintColor`. export const TG_ICONS = { - back: require('../../../assets/telegram/back.png'), - close: require('../../../assets/telegram/close.png'), - paperclip: require('../../../assets/telegram/paperclip.png'), - sticker: require('../../../assets/telegram/sticker.png'), - mic: require('../../../assets/telegram/mic.png'), - checks: require('../../../assets/telegram/checks.png'), - viewed: require('../../../assets/telegram/viewed.png'), - reply: require('../../../assets/telegram/reply.png'), - copy: require('../../../assets/telegram/copy.png'), - edit: require('../../../assets/telegram/edit.png'), - pin: require('../../../assets/telegram/pin.png'), - forward: require('../../../assets/telegram/forward.png'), - delete: require('../../../assets/telegram/delete.png'), - select: require('../../../assets/telegram/select.png'), - chevron: require('../../../assets/telegram/chevron.png'), + back: require('../../../assets/chat/back.png'), + plus: require('../../../assets/chat/plus.png'), + mic: require('../../../assets/chat/mic.png'), + reply: require('../../../assets/chat/reply.png'), + copy: require('../../../assets/chat/copy.png'), + delete: require('../../../assets/chat/trash.png'), }; -export const TG_PATTERN = require('../../../assets/telegram/pattern.png'); -// Header avatar (public GitHub avatar). -export const TG_AVATAR_PHOTO = require('../../../assets/telegram/avatar.jpg'); +export const TG_PATTERN = require('../../../assets/chat/pattern.png'); +export const TG_AVATAR_PHOTO = require('../../../assets/chat/avatar-peaks.png'); -// Colours sampled from the recording (Telegram iOS, dark theme, purple accent). -export const TG_BLUE = '#3E88F7'; -export const TG_BUBBLE = '#7C40FB'; -export const TG_BUBBLE_TOP = '#8A3EFF'; -export const TG_RED = '#FF453A'; -export const TG_PILL = '#191919'; -export const TG_MENU = '#161216'; -export const TG_MUTED = '#8E8E93'; -export const TG_AVATAR = '#4EB9FA'; +export const TG_BUBBLE = '#4F4A8C'; +export const TG_RED = '#FF6B5E'; +export const TG_PILL = '#141B1A'; +export const TG_MENU = '#1B2422'; +export const TG_MUTED = '#8FA09B'; diff --git a/example/src/components/wallet/CardArt.tsx b/example/src/components/wallet/CardArt.tsx index b34a043..392eab8 100644 --- a/example/src/components/wallet/CardArt.tsx +++ b/example/src/components/wallet/CardArt.tsx @@ -1,7 +1,5 @@ import { StyleSheet, Text, View } from 'react-native'; -import { CARD_RADIUS } from './CardSurface'; - /** EMV chip: a gold pad with its contact segments. */ function Chip() { return ( @@ -14,92 +12,26 @@ function Chip() { ); } -/** 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 ( - - - - - - - ); +/** An invented issuer mark: a diamond outline. */ +function IssuerMark() { + return ; } /** Printed and engraved details laid over the metal surface. */ export function CardArt() { return ( - Neobank + Quill Reserve Titanium - - •••• 1834 - + 0000 0000 + ); } -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', @@ -170,20 +102,6 @@ const styles = StyleSheet.create({ 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, @@ -194,65 +112,14 @@ const styles = StyleSheet.create({ 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: { + mark: { 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', + right: 28, + bottom: 24, + width: 24, + height: 24, + borderWidth: 2.5, + borderColor: 'rgba(235, 237, 240, 0.8)', + transform: [{ rotate: '45deg' }], }, }); diff --git a/example/src/components/wallet/PayGlyph.tsx b/example/src/components/wallet/PayGlyph.tsx index 88bc284..95c88bd 100644 --- a/example/src/components/wallet/PayGlyph.tsx +++ b/example/src/components/wallet/PayGlyph.tsx @@ -8,19 +8,13 @@ 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. - */ + /** `u.live = (ring, spark, 0, 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. + * A progress ring that draws itself around the glyph's centre, then closes + * into a four-pointed spark. */ export default function PayGlyph({ paramsSynchronizable, @@ -45,125 +39,33 @@ struct Uniforms { 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); +const TAU = 6.2832; +const MINT = vec3(0.5, 0.84, 0.75); -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); +fn sparkDistance(c: vec2, size: f32) -> f32 { + let q = abs(c) / max(size, 0.001); + return (pow(q.x, 0.55) + pow(q.y, 0.55) - 1.0) * size; } @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 ring = u.live.x; + let spark = u.live.y; 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 angle = fract(atan2(c.x, c.y) / TAU + 1.0); + let band = 1.0 - smoothstep(0.06 - px, 0.06 + px, abs(r - 0.78)); + let drawn = step(angle, ring) * step(0.001, ring); + let ringAlpha = band * drawn * (1.0 - spark); - 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); + let sparkAlpha = (1.0 - smoothstep(-px, px, sparkDistance(c, 0.82 * spark))) * step(0.001, spark); - 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); + let alpha = max(ringAlpha, sparkAlpha); + return vec4(MINT * alpha, alpha); } `; diff --git a/example/src/components/wallet/chime.ts b/example/src/components/wallet/chime.ts index fe23cfc..8cf5a14 100644 --- a/example/src/components/wallet/chime.ts +++ b/example/src/components/wallet/chime.ts @@ -16,7 +16,7 @@ function tone(ac: AudioContext, freq: number, at: number, length: number) { osc.stop(at + length + 0.02); } -/** A soft two-note success chime, in the spirit of the Apple Pay ding. */ +/** A soft two-note success chime, for the reveal. */ export function playChime() { try { ctx = ctx ?? new AudioContext(); diff --git a/example/src/hooks/useAudioReactive.ts b/example/src/hooks/useAudioReactive.ts index 77b5d06..b0acb4f 100644 --- a/example/src/hooks/useAudioReactive.ts +++ b/example/src/hooks/useAudioReactive.ts @@ -126,10 +126,7 @@ export function useAudioReactive(options: AudioReactiveOptions = {}): { // so its return value can't be trusted. Verify the real state instead. recorder.start(); if (!recorder.isRecording()) { - setError( - 'No microphone input. On the iOS Simulator pick a host mic via ' + - 'Simulator → I/O → Audio Input, or run on a physical device.' - ); + setError('No microphone input found on this device.'); stop(); return; } diff --git a/example/src/hooks/useIMessageEffects.ts b/example/src/hooks/useIMessageEffects.ts index 5582054..b9b8bf8 100644 --- a/example/src/hooks/useIMessageEffects.ts +++ b/example/src/hooks/useIMessageEffects.ts @@ -8,41 +8,40 @@ import { type ScreenEffectId, } from '../components/imessage/types'; -// iMessage starts the screen effect a beat after the bubble lands, so the eye -// has something to anchor the effect to. +// The screen effect starts a beat after the bubble lands, so the eye has +// something to anchor it to. export const EFFECT_START_DELAY_MS = 150; -// A scripted demo. It opens with one text from Kacper; each effect has its -// own line from "me" written to match the effect's energy, and Kacper answers -// a beat later, except after the spotlight, which needs the last word. -// Fond, not bitter: the jokes are about the reasoning, not the people. -const OPENER = 'shopify is going back to native 💀'; +// A scripted demo. It opens with one text from Noa; each effect has its own +// line from "me" written to match the effect's energy, and Noa answers a beat +// later, except after the spotlight, which needs the last word. +const OPENER = 'guess who got the apartment 🔑'; type Step = { effect: ScreenEffectId; me: string; reply: string | null }; const STEPS: Step[] = [ { effect: 'echo', - me: 'back to native? from what, react NATIVE?', - reply: 'their whole point is agents can port anything now', + me: 'wait, the one with the balcony?', + reply: 'the one with the balcony', }, { effect: 'lasers', - me: 'great. tell the agents to port it back', - reply: 'they rebuilt shop in 12 weeks though', + me: 'send me the address. i am bringing plants', + reply: 'small plants only please', }, { effect: 'fireworks', - me: '12 weeks and zero pod installs. jealous honestly', - reply: 'still shipping flashlist fixes though', + me: 'small plants, big party', + reply: 'saturday at eight?', }, { effect: 'confetti', - me: "flashlist does 1M a week. you don't just leave that", - reply: 'so what do we do', + me: 'saturday at eight. i will bring cake', + reply: 'what kind of cake', }, { effect: 'spotlight', - me: 'keep shipping. react native for the win', + me: 'the best kind', reply: null, }, ]; @@ -50,19 +49,19 @@ const STEPS: Step[] = [ /** The effects in the order the demo means them to be used. */ export const DEMO_ORDER: ScreenEffectId[] = STEPS.map((s) => s.effect); -/** The beat between the effect ending and Kacper's reply landing. */ +/** The beat between the effect ending and Noa's reply landing. */ export const REPLY_DELAY_MS = 450; /** If an effect never reports finishing, reply anyway after this long. */ const REPLY_FALLBACK_MS = 8000; /** What a button or the debug hook sends once the script has run out. */ export const DEFAULT_TEXT: Record = { - none: 'hello from the new architecture', - echo: 'back to native', - spotlight: 'read the january post again', - confetti: 'skia hit 2M downloads a week 🎉', - fireworks: 'new arch is the default now', - lasers: 'hermes go brrr', + none: 'hello there', + echo: 'say that again', + spotlight: 'all eyes on this one', + confetti: 'we did it 🎉', + fireworks: 'happy new year', + lasers: 'a little sunshine', }; export type Playing = { @@ -72,8 +71,8 @@ export type Playing = { messageId: string; }; -// Every bubble that a screen effect plays for reports its window rect (and -// snapshot) here once laid out, so the effect can start where the bubble is. +// Every bubble that a screen effect plays for reports its window rect here +// once laid out, so the effect can start where the bubble is. const targets = new Map(); const targetWaiters = new Map void)[]>(); @@ -90,23 +89,14 @@ export function forgetTarget(id: string) { targets.delete(id); } -function waitForTarget( - id: string, - needSnapshot: boolean -): Promise { +function waitForTarget(id: string): Promise { const have = targets.get(id); - if (have && (!needSnapshot || have.snapshotUri)) { + if (have) { return Promise.resolve(have); } return new Promise((resolve) => { const list = targetWaiters.get(id) ?? []; - list.push((t) => { - if (!needSnapshot || t.snapshotUri) { - resolve(t); - } else { - waitForTarget(id, true).then(resolve); - } - }); + list.push(resolve); targetWaiters.set(id, list); }); } @@ -127,8 +117,8 @@ function stamp() { /** * The conversation and the one screen effect that can play at a time. `send` * appends a real bubble; when it carries a screen effect, the effect starts - * once the bubble has laid out and reported where it is (and, for echo, has - * been snapshotted), never sooner than the iMessage beat. + * once the bubble has laid out and reported where it is, never sooner than + * the start delay. */ export function useIMessageEffects() { const [messages, setMessages] = useState(() => [ @@ -139,7 +129,7 @@ export function useIMessageEffects() { // Steps already spoken; a second press of the same effect falls back to // the default line and gets no reply. const spoken = useRef(new Set()); - // Kacper's next line, held until the effect has finished playing. While it + // Noa's next line, held until the effect has finished playing. While it // is held, presses are ignored so a quick run of taps cannot post three // lines before his first answer lands. const pendingReply = useRef<{ @@ -199,7 +189,7 @@ export function useIMessageEffects() { setMessages((prev) => [...prev, msg]); if (isScreenEffect(effect)) { const startedAt = Date.now(); - waitForTarget(id, effect === 'echo').then(() => { + waitForTarget(id).then(() => { const wait = Math.max( 0, EFFECT_START_DELAY_MS - (Date.now() - startedAt) @@ -210,7 +200,7 @@ export function useIMessageEffects() { }, wait); }); } - // Kacper waits for the effect to end (finishPlaying schedules the + // Noa waits for the effect to end (finishPlaying schedules the // delivery), with a fallback in case it never reports back. if (reply !== null) { setBusy(true); @@ -255,7 +245,7 @@ export function useIMessageEffects() { /** * Dev-only globals so the simulator can drive the screen without the - * keyboard or a long-press: + * keyboard: * - `__imsgSend(effect, text?)` — append an outgoing bubble and play its * effect; `effect` is one of none, echo, spotlight, confetti, * fireworks, lasers. Returns the new message id. diff --git a/example/src/hooks/useMagic8BallPhysics.ts b/example/src/hooks/useMagic8BallPhysics.ts index d85fb89..cd6e159 100644 --- a/example/src/hooks/useMagic8BallPhysics.ts +++ b/example/src/hooks/useMagic8BallPhysics.ts @@ -28,18 +28,18 @@ const CHURN_RELEASE = 1.45; /** Churn that knocks a settled answer back off the window. */ const SHAKE_TRIGGER = 0.3; /** - * Churn the fluid has to fall below before the die can float back up. Set by + * Churn the fluid has to fall below before the answer can float back up. Set by * pacing, not by physics: the fluid is still visibly moving at 0.2, and * waiting for it to go properly still leaves three seconds of staring at murk. */ const CALM = 0.2; -/** How fast a churning fluid drags the die back down, in rise units per second. */ +/** How fast a churning fluid drags the answer back down, in rise units per second. */ const SINK_RATE = 1.5; /** * Buoyancy: a CONSTANT push, against linear drag — not a spring pulling the - * die toward the window. + * answer toward the window. * * This is the difference between the answer floating up and the answer popping * up. A spring is slowest where it starts and fastest where it arrives, so it @@ -63,15 +63,15 @@ const SWAY_DAMPING = 3.2; const SWAY_KICK = 2.1; /** - * How far off square the die is when it starts its climb (rad), and the spring - * that turns it back. A die arriving already flat to the window is the single + * How far off square the answer is when it starts its climb (rad), and the spring + * that turns it back. An answer arriving already flat to the window is the single * most artificial thing this screen can do: it has been tumbling in the fluid, * so it has to come up turned and rotate the last few degrees into place. * * Under-damped on purpose — it overshoots square by a couple of degrees and * comes back, which is what a face settling against glass in a thick fluid * does. Softer than the rise spring, so the turn is still finishing after the - * die has touched: two motions ending together read as one scripted move. + * answer has touched: two motions ending together read as one scripted move. */ const SPIN_MIN = 0.34; const SPIN_MAX = 0.72; @@ -79,15 +79,15 @@ const SPIN_STIFFNESS = 5; const SPIN_DAMPING = 1.9; /** - * A second, much softer spring that carries the die's ORIENTATION to world-up. + * A second, much softer spring that carries the answer's ORIENTATION to world-up. * * It cannot be the tilt spring. That one is stiff because it IS the hand — the * rise axis has to answer the phone immediately — but tilt comes from atan2 of * a raw accelerometer, which carries a degree or so of noise plus whatever the - * hand is doing. Rotating the die (and the answer printed on it) straight off + * hand is doing. Rotating the answer (and the answer printed on it) straight off * that makes the text tremble while the phone is held still. * - * Half a second to follow, well damped: noise never survives it, and a die + * Half a second to follow, well damped: noise never survives it, and an answer * that heavy in a fluid that thick should lag the phone anyway. */ const ORIENT_STIFFNESS = 20; @@ -101,9 +101,9 @@ const SWIRL_CHURNED = 2.2; const SYNC_EPSILON = 0.004; /** - * Where the die sits when it is still deep in the murk, in window radii. - * Hard-coded to the same number in the shader, which draws the die this text - * sits on — change one and you change both. + * Where the answer sits when it is still deep in the murk, in window radii. + * Hard-coded to the same number in the shader, which opens the clearing this + * text sits in — change one and you change both. */ const SUBMERGED_DEPTH = 1.2; /** How far the sway pushes it off the rise axis, in window radii. */ @@ -130,13 +130,13 @@ type BallSim = { /** 0 = lost in the murk, 1 = pressed against the window. */ rise: number; riseVel: number; - /** Signed sideways wobble of the die, in window radii-ish. */ + /** Signed sideways wobble of the answer, in window radii-ish. */ sway: number; swayVel: number; - /** How far the die is turned off square (rad), and how fast. */ + /** How far the answer is turned off square (rad), and how fast. */ spin: number; spinVel: number; - /** The die's heading, lagging world-up (rad). */ + /** The answer's heading, lagging world-up (rad). */ orient: number; orientVel: number; /** Integrated phase — never time x a speed that varies at runtime. */ @@ -149,16 +149,16 @@ export type Magic8BallPhysics = { >['paramsSynchronizable']; /** Shake it by hand — a tap on the ball, or the debug hook. */ shake: (strength?: number) => void; - /** 0..1, how far the die has floated up. Drives the answer text. */ + /** 0..1, how far the answer has floated up. Drives the answer text. */ rise: SharedValue; /** - * Where the die is, relative to the middle of the window, in window radii. + * Where the answer is, relative to the middle of the window, in window radii. * Screen convention: y grows DOWNWARD, so it can be fed straight into a * transform. */ drift: SharedValue<{ x: number; y: number }>; /** - * How the die is turned, in DEGREES and in the screen's sense (positive = + * How the answer is turned, in DEGREES and in the screen's sense (positive = * clockwise), so the answer text turns with the face it is printed on. Both * the world-up alignment and the spin left over from the climb. */ @@ -166,18 +166,18 @@ export type Magic8BallPhysics = { }; /** - * The physics of a Magic 8-ball. + * The physics of a mystic ball. * * One input: the accelerometer. Its in-plane gravity angle says which way is - * really up, and the die floats up along that axis — tip the phone and the + * really up, and the answer floats up along that axis — tip the phone and the * answer rises in from a different corner of the window. Differentiating the - * same signal gives a shake, which churns the fluid; churn drags the die back + * same signal gives a shake, which churns the fluid; churn drags the answer back * down off the glass, and only once the fluid has calmed does buoyancy carry * a new answer up into the window, bumping the glass once as it arrives. * * Writes the live channel every frame: * `u.live = (tilt rad, churn 0..1, rise 0..1, swirlPhase)`, - * `u.liveData[0] = (sway, die rotation rad, 0, 0)`. + * `u.liveData[0] = (sway, answer rotation rad, 0, 0)`. */ export function useMagic8BallPhysics({ onSubmerged, @@ -370,7 +370,7 @@ export function useMagic8BallPhysics({ } if (s.phase === 'sinking') { - // The more violently the fluid is moving, the faster it strips the die + // The more violently the fluid is moving, the faster it strips the answer // off the glass. s.rise -= SINK_RATE * dt * (0.35 + s.churn); if (s.rise <= 0) { @@ -396,7 +396,7 @@ export function useMagic8BallPhysics({ s.rise += s.riseVel * dt; if (s.rise >= 1) { // It has met the glass; it cannot go further, and most of the - // energy goes into the fluid rather than back into the die. + // energy goes into the fluid rather than back into the answer. s.rise = 1; if (s.riseVel > GLASS_SETTLE) { s.riseVel = -s.riseVel * GLASS_BOUNCE; @@ -428,14 +428,14 @@ export function useMagic8BallPhysics({ s.rise, s.swirlPhase, s.sway, - // One slot carries the die's whole rotation: the heading it is settling + // One slot carries the answer's whole rotation: the heading it is settling // to, plus the spin the climb left behind. s.spin + s.orient, 0, 0 ); - // The answer text has to sit on the die, so it needs the same offset the + // The answer text has to sit on the answer, so it needs the same offset the // shader computes: up the world-up axis as it rises, plus the sway // across it. Pushed to the UI thread only when it has actually moved, so // a settled ball costs nothing. @@ -443,7 +443,7 @@ export function useMagic8BallPhysics({ rise.value = s.rise; syncedRise = s.rise; } - // The text is printed on the die, so it takes the die's whole rotation: + // The text is printed on the answer, so it takes the answer's whole rotation: // world-up alignment plus whatever spin the climb has left. uv rotates // counter-clockwise, a transform clockwise. const turn = s.spin + s.orient; diff --git a/example/src/hooks/usePolaroidShake.ts b/example/src/hooks/usePolaroidShake.ts index a816fff..b163250 100644 --- a/example/src/hooks/usePolaroidShake.ts +++ b/example/src/hooks/usePolaroidShake.ts @@ -26,7 +26,7 @@ type ShakeSim = { type DebugGlobal = { __polaroidShake?: (level: number | null) => void }; /** - * Shaking the phone like a Polaroid picture. + * Shaking the phone like an instant print. * * Reads the accelerometer: anything beyond gravity is shake, run through an * envelope into `level` (0..1), which the screen uses to speed development a diff --git a/example/src/hooks/useScratchCard.ts b/example/src/hooks/useScratchCard.ts index 1930717..9b4531d 100644 --- a/example/src/hooks/useScratchCard.ts +++ b/example/src/hooks/useScratchCard.ts @@ -135,9 +135,7 @@ export function useScratchCard() { if (through >= cells.length * AUTO_REVEAL_AT) { revealing.current[i] = true; Vibration.vibrate(20); - if (i < 3) { - setRevealed((n) => n + 1); - } + setRevealed((n) => n + 1); } }); }, []); diff --git a/example/src/hooks/useSpoiler.ts b/example/src/hooks/useSpoiler.ts index 261669a..f38e789 100644 --- a/example/src/hooks/useSpoiler.ts +++ b/example/src/hooks/useSpoiler.ts @@ -12,7 +12,7 @@ import { type ParamsSynchronizable, } from 'react-native-effects'; -// Telegram's spoiler timeline: the burst clears the text in about a third of a +// The spoiler timeline: the burst clears the text in about a third of a // second, the text stays readable for a few seconds, then the cloud settles // back over it. export const BURST_MS = 420; @@ -116,7 +116,7 @@ export function useSpoiler({ id, order }: { id: string; order: number }): { phase.current = 'hiding'; textOpacity.value = withTiming(0, { duration: TEXT_FADE_MS }); // The particles come back at their resting places, not by replaying the - // burst backwards; that is what Telegram does and it reads as settling. + // burst backwards; it reads as settling. burst.value = 0; cover.value = withTiming( 1, diff --git a/example/src/screens/AiShimmerScreen.tsx b/example/src/screens/AiShimmerScreen.tsx index 924c862..d0babad 100644 --- a/example/src/screens/AiShimmerScreen.tsx +++ b/example/src/screens/AiShimmerScreen.tsx @@ -13,8 +13,10 @@ import ShimmerSweep from '../components/ShimmerSweep'; const THINK_MS = 2600; +const QUESTION = 'What can I cook with eggs, spinach and feta?'; + 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 .'; + 'A quick frittata would work well. Wilt the spinach in a little butter, pour over four beaten eggs, crumble in the feta and cook gently until just set. Finish under the grill for a golden top.'; /** Grey placeholder lines that the iridescent shimmer sweeps across. */ function Skeleton() { @@ -56,7 +58,7 @@ export default function AiShimmerScreen() { Assistant - {thinking ? 'Generating…' : 'react-native-effects'} + {thinking ? 'Generating…' : 'Recipe ideas'} @@ -64,14 +66,12 @@ export default function AiShimmerScreen() { style={styles.scroll} contentContainerStyle={styles.scrollContent} > - {/* User message */} - What is react-native-effects? + {QUESTION} - {/* Assistant message */} ✦ @@ -80,7 +80,6 @@ export default function AiShimmerScreen() { {thinking ? ( <> - {/* The react-native-effects shimmer, clipped to the bubble. */} - - - - {GRADIENT_PRESETS.map((preset) => ( - - - - - - {preset.name} - - - - Center - - - - Edge - - - - - ))} - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#000', - }, - scrollView: { - flex: 1, - }, - scrollContent: { - padding: 40, - paddingTop: 16, - paddingBottom: 80, - alignItems: 'center', - }, - gradientCard: { - width: CARD_WIDTH, - marginBottom: 32, - backgroundColor: '#111', - borderRadius: 24, - overflow: 'hidden', - borderWidth: 1, - borderColor: '#252525', - }, - gradientWrapper: { - width: '100%', - height: 240, - overflow: 'hidden', - }, - gradient: { - width: '100%', - height: '100%', - }, - gradientInfo: { - padding: 24, - }, - gradientName: { - fontSize: 24, - fontWeight: '700', - color: '#fff', - marginBottom: 16, - letterSpacing: -0.5, - }, - colorInfo: { - flexDirection: 'row', - justifyContent: 'space-between', - }, - colorSwatch: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - marginRight: 12, - }, - colorDot: { - width: 20, - height: 20, - borderRadius: 10, - marginRight: 10, - borderWidth: 2, - borderColor: '#333', - }, - colorText: { - fontSize: 13, - fontFamily: 'monospace', - color: '#aaa', - fontWeight: '500', - }, -}); diff --git a/example/src/screens/CrackedScreenScreen.tsx b/example/src/screens/CrackedScreenScreen.tsx index 67aa796..6196d43 100644 --- a/example/src/screens/CrackedScreenScreen.tsx +++ b/example/src/screens/CrackedScreenScreen.tsx @@ -2,6 +2,7 @@ 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 EffectCaption from '../components/cracks/EffectCaption'; import LockScreen from '../components/cracks/LockScreen'; import { useCrackedGlass } from '../components/cracks/useCrackedGlass'; import { useSnapshotTexture } from '../components/cracks/useSnapshotTexture'; @@ -12,14 +13,10 @@ import { useSnapshotTexture } from '../components/cracks/useSnapshotTexture'; * (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. + * Tap anywhere and the glass breaks where you hit it. Long press to put a + * fresh pane on. */ export default function CrackedScreenScreen() { const lockRef = useRef>(null); @@ -47,12 +44,7 @@ export default function CrackedScreenScreen() { diff --git a/example/src/screens/DuoCrackgateScreen.tsx b/example/src/screens/DuoCrackgateScreen.tsx index b64b59a..52e1e27 100644 --- a/example/src/screens/DuoCrackgateScreen.tsx +++ b/example/src/screens/DuoCrackgateScreen.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef } from 'react'; import { StatusBar, StyleSheet, useWindowDimensions, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import EffectCaption from '../components/cracks/EffectCaption'; import HingeFailure from '../components/cracks/HingeFailure'; import LockScreen from '../components/cracks/LockScreen'; import { useHingeFailure } from '../components/cracks/useHingeFailure'; @@ -17,11 +18,10 @@ const RESET_HOLD_MS = 600; const FOLD_SETTLE_MS = 700; /** - * The iPhone Duo "crackgate" inner display failure. The lock screen looks - * fine for a moment, then the hinge gives out on its own. Folding or - * unfolding the phone (a window size change) replays it, a tap pushes the - * damage one stage further or replays a dead panel, and a long press puts a - * fresh panel in. On phones without a fold the hinge is the centre line. + * An imagined folding display failing along its crease. The scene looks fine + * for a moment, then the hinge gives out on its own. A window size change + * replays it, a tap pushes the damage one stage further or replays a dead + * panel, and a long press puts a fresh panel in. The hinge is the centre line. */ export default function DuoCrackgateScreen() { const { width, height } = useWindowDimensions(); @@ -59,7 +59,6 @@ export default function DuoCrackgateScreen() { height} clockColor={CLOCK_ORANGE} dim={0.4} liveClock @@ -69,6 +68,7 @@ export default function DuoCrackgateScreen() { style={styles.fill} pointerEvents="none" /> + diff --git a/example/src/screens/FlashlightScreen.tsx b/example/src/screens/FlashlightScreen.tsx index 1a4ea7c..9983697 100644 --- a/example/src/screens/FlashlightScreen.tsx +++ b/example/src/screens/FlashlightScreen.tsx @@ -1,17 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { - Image, - Pressable, - StyleSheet, - useWindowDimensions, - View, -} from 'react-native'; +import { StyleSheet, useWindowDimensions, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useParamsSynchronizable } from 'react-native-effects'; +import TorchControls from '../components/flashlight/TorchControls'; import TorchScene from '../components/flashlight/TorchScene'; -import TorchIsland, { - type IslandMode, -} from '../components/flashlight/TorchIsland'; import { useTorchAim } from '../hooks/useTorchAim'; /** @@ -21,39 +13,32 @@ import { useTorchAim } from '../hooks/useTorchAim'; */ 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. + * A torch in a dark corridor. The sliders set the beam width and brightness, + * and tilting the phone sweeps the spot across the photo. */ 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 [isOn, setIsOn] = useState(true); + const [beam, setBeam] = useState(0.55); + const [level, setLevel] = useState(0.75); + const target = useRef({ beam, level, power: 1 }); + const state = useRef({ beam, level, power: 1, 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 scene = useParamsSynchronizable([ + width / 2, + height * 0.45, + beam, + level, + ]); const setScene = scene.setParamsSynchronizable; - const setIsland = island.setParamsSynchronizable; useEffect(() => { let frame = 0; @@ -72,39 +57,26 @@ export default function FlashlightScreen() { 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); - }, []); + }, [aim, width, height, setScene]); - const onLockButton = useCallback(() => { - const next = !isOn; - setTorch(next); - setMode(next ? 'expanded' : 'idle'); - }, [isOn, setTorch]); + const toggle = useCallback(() => { + target.current.power = isOn ? 0 : 1; + setIsOn(!isOn); + }, [isOn]); - 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 changeBeam = useCallback((value: number) => { + target.current.beam = value; + setBeam(value); + }, []); - const collapse = useCallback(() => { - if (mode === 'expanded') setMode(isOn ? 'compact' : 'idle'); - }, [mode, isOn]); + const changeLevel = useCallback((value: number) => { + target.current.level = value; + setLevel(value); + }, []); return ( @@ -114,34 +86,15 @@ export default function FlashlightScreen() { photoAspect={CORRIDOR_ASPECT} paramsSynchronizable={scene.paramsSynchronizable} /> - - setMode('expanded')} - onToggle={onIslandToggle} - onAdjust={onAdjust} + beam={beam} + level={level} + bottomInset={insets.bottom} + onToggle={toggle} + onBeamChange={changeBeam} + onLevelChange={changeLevel} /> - - - - - - - - - ); } @@ -151,46 +104,4 @@ const styles = StyleSheet.create({ 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 deleted file mode 100644 index 1d8d952..0000000 --- a/example/src/screens/FoamLabScreen.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { - StatusBar, - StyleSheet, - Text, - View, - useWindowDimensions, -} from 'react-native'; -import { ShaderView } from 'react-native-effects'; - -/** - * Foam lab: the same tilted beer three times, each with a photographed head - * riding the surface, at the same on-screen scale as the real glass, so the - * treatment can be picked by eye and moved into BeerGlass. - * - * Procedural heads (grain, cells, drawn bubbles) all read as drawn, so the - * head is now a photo sampled in the surface-aligned frame, the way iBeer - * does it. The photo has beer under its foam; tiles 2 and 3 key that out on - * yellowness so the lacy seam of half-transparent bubbles is what sits on - * our own beer. - */ -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. -const FOAM_PHOTO = require('../../assets/foam.jpg'); - -export default function FoamLabScreen() { - const { height } = useWindowDimensions(); - const tileH = height / VARIANTS.length; - // Texture frequencies in the lab are written per SCREEN height, like the - // real glass, so scale by the tile's share of the screen. - const scale = tileH / height; - - return ( - - - {VARIANTS.map((name, i) => ( - - - {name} - - ))} - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: '#000' }, - label: { - position: 'absolute', - left: 8, - bottom: 8, - color: '#fff', - fontSize: 12, - fontWeight: '600', - textShadowColor: '#000', - textShadowRadius: 4, - }, -}); - -const FOAM_LAB_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; - -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 photo: square, foam over beer, the foam's lacy bottom line at v = 0.615 -// from the top. PHOTO_H is its height in screen-height units, chosen so the -// head above the seam is ~0.17 of the screen, like the iBeer head. -const PHOTO_H: f32 = 0.28; -const PHOTO_SEAM: f32 = 0.615; - -@fragment -fn main(@location(0) ndc: vec2) -> @location(0) vec4 { - let t = u.time.x; - let aspect = u.resolution.z; - let uv = ndc * 0.5 + 0.5; - let variant = i32(u.params0.x + 0.5); - let scale = max(u.params0.y, 0.05); // tile height / screen height - let p = vec2((uv.x - 0.5) * aspect, uv.y); - - // A gently swaying tilted surface low in the tile, so the whole head and - // the glass above it are in view. - let ang = 0.12 * sin(t * 0.7); - let dn = vec2(sin(ang), -cos(ang)); - let es = vec2(cos(ang), sin(ang)); - let ctr = vec2(0.0, 0.2); - let depth = dot(p - ctr, dn); // > 0 inside the beer - let s = dot(p - ctr, es); - // Surface-aligned frame in SCREEN-height units. fd.y grows upward: - // positive above the surface, inside the head. - let fd = vec2(s, -depth) * scale; - let foamThick = PHOTO_H * PHOTO_SEAM; - let feather = 3.0 / u.resolution.y; - - // Beer body: amber with a depth gradient and a few bubbles. - var beer = u.color0.rgb * (1.0 - clamp(depth * 1.2, 0.0, 1.0) * 0.2); - let bq = fd * 18.0 + vec2(0.0, -t * 1.5); - let bc = fract(bq) - 0.5; - let br = hash21(floor(bq)); - let bub = step(0.55, br) * smoothstep(0.12, 0.05, length(bc)); - beer = beer + vec3(1.0, 0.95, 0.8) * bub * 0.3; - // The beer runs a little way up under the head, so the half-transparent - // seam bubbles sit on beer, never on the dark glass. - let inLiquid = smoothstep(0.0, feather, depth + 0.04 / scale); - - // Big soft mounds on the silhouette against the glass. - let lumpA = vnoise(vec2(fd.x * 3.5, t * 0.05)); - let lumpB = vnoise(vec2(fd.x * 9.0 + 4.0, t * 0.08)); - let topH = foamThick * (0.55 + lumpA * 0.62 + lumpB * 0.22); - let dFs = -fd.y; // screen-unit depth below surface - let topMask = smoothstep(-topH, -topH + feather * scale, dFs); - - // The photo in the surface frame, its seam on the surface, mirrored - // sideways so it tiles without a visible join. - let puv = vec2(fd.x / PHOTO_H + 0.5, PHOTO_SEAM - fd.y / PHOTO_H); - let c = textureSampleLevel(tex, samp, puv, 0.0).rgb; - // Yellowness keys the photo's own beer out: foam is ~0.13, beer ~0.85, the - // half-transparent seam bubbles land in between. Dark pixels (bubble rims - // over beer) are beer too, or they survive as black specks on the seam. - let lumn = dot(c, vec3(0.299, 0.587, 0.114)); - let beerness = max(smoothstep(0.35, 0.6, c.r - c.b), - 1.0 - smoothstep(0.3, 0.55, lumn)); - - var alpha = topMask; - var foam = c; - if (variant == 0) { - // As shot: the photo's own beer stays, down to the bottom of the frame. - alpha = alpha * step(puv.y, 1.0); - } else { - // Nothing of the photo below its seam band: the photo's beer has bright - // droplets that would survive the key as specks in ours. - alpha = alpha * (1.0 - beerness) * smoothstep(-0.014, -0.008, fd.y); - // Half-keyed seam bubbles lose their yellow so they read as translucent - // white over our beer, not the photo's. - foam = mix(c, u.color1.rgb * min(lumn * 1.15, 1.0), beerness); - } - if (variant == 2) { - // Toward the iBeer white: keep the photo's shading, drop its cream. - foam = mix(foam, u.color1.rgb * lumn * 1.04, 0.6); - // Wet where it sits on the beer. - let wet = (1.0 - smoothstep(0.0, 0.03, fd.y)) * 0.25; - foam = mix(foam, beer * 1.05, wet); - } - - // Glass headspace. - let glass = vec3(0.1, 0.065, 0.032); - var col = mix(glass, beer, inLiquid); - col = mix(col, foam, alpha); - // Tile edge shading. - let ex = abs(uv.x - 0.5) * 2.0; - col = col * (1.0 - 0.25 * ex * ex * ex); - return vec4(clamp(col, vec3(0.0), vec3(1.0)), 1.0); -} -`; diff --git a/example/src/screens/HoloCardScreen.tsx b/example/src/screens/HoloCardScreen.tsx index 61f8fb3..12de40d 100644 --- a/example/src/screens/HoloCardScreen.tsx +++ b/example/src/screens/HoloCardScreen.tsx @@ -4,29 +4,29 @@ import { Aurora, LiquidChrome, Iridescence } from 'react-native-effects'; const CARDS = [ { - id: 'revolut', - name: 'REVOLUT', - number: '•••• •••• •••• 7291', - holder: 'ALEX MORGAN', - expiry: '09/28', + id: 'moss-ember', + name: 'MOSS & EMBER', + number: '0000 0000 0000 0000', + holder: 'SAMPLE CARD', + expiry: '00/00', effect: 'aurora' as const, darkText: false, }, { - id: 'monzo', - name: 'MONZO', - number: '•••• •••• •••• 4242', - holder: 'JAMIE CHEN', - expiry: '03/27', + id: 'tidewater', + name: 'TIDEWATER MINT', + number: '1234 5678 9012 3456', + holder: 'SAMPLE CARD', + expiry: '00/00', effect: 'iridescence' as const, darkText: false, }, { - id: 'neobank', - name: 'NEOBANK', - number: '•••• •••• •••• 1834', - holder: 'SAM TAYLOR', - expiry: '12/29', + id: 'quill', + name: 'QUILL RESERVE', + number: '0123 4567 8901 2345', + holder: 'SAMPLE CARD', + expiry: '00/00', effect: 'chrome' as const, darkText: true, }, @@ -102,11 +102,6 @@ export default function HoloCardScreen() { style={[styles.chipInner, dark && styles.chipInnerDark]} /> - - - - - {card.name} @@ -201,45 +196,6 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: 'rgba(180, 140, 20, 0.6)', }, - contactless: { - width: 28, - height: 28, - justifyContent: 'center', - alignItems: 'center', - }, - arc1: { - position: 'absolute', - width: 10, - height: 10, - borderRadius: 10, - borderWidth: 1.5, - borderColor: 'rgba(255, 255, 255, 0.6)', - borderLeftColor: 'transparent', - borderBottomColor: 'transparent', - transform: [{ rotate: '45deg' }], - }, - arc2: { - position: 'absolute', - width: 17, - height: 17, - borderRadius: 17, - borderWidth: 1.5, - borderColor: 'rgba(255, 255, 255, 0.6)', - borderLeftColor: 'transparent', - borderBottomColor: 'transparent', - transform: [{ rotate: '45deg' }], - }, - arc3: { - position: 'absolute', - width: 24, - height: 24, - borderRadius: 24, - borderWidth: 1.5, - borderColor: 'rgba(255, 255, 255, 0.6)', - borderLeftColor: 'transparent', - borderBottomColor: 'transparent', - transform: [{ rotate: '45deg' }], - }, cardName: { fontSize: 18, fontWeight: '700', @@ -289,9 +245,4 @@ const styles = StyleSheet.create({ chipInnerDark: { borderColor: 'rgba(140, 110, 10, 0.7)', }, - arcDark: { - borderColor: 'rgba(0, 0, 0, 0.5)', - borderLeftColor: 'transparent', - borderBottomColor: 'transparent', - }, }); diff --git a/example/src/screens/HourglassScreen.tsx b/example/src/screens/HourglassScreen.tsx index 0a6e50e..c65ceb9 100644 --- a/example/src/screens/HourglassScreen.tsx +++ b/example/src/screens/HourglassScreen.tsx @@ -46,7 +46,7 @@ function formatTime(seconds: number): string { } /** - * An hourglass that is a Pomodoro timer. + * An hourglass that is a focus timer. * * A photograph of a wooden hourglass; the sand in it is drawn from the time * left. Tilt the phone and the sand tilts with real gravity, holding its diff --git a/example/src/screens/IMessageScreen.tsx b/example/src/screens/IMessageScreen.tsx index a749bce..e7b1ec9 100644 --- a/example/src/screens/IMessageScreen.tsx +++ b/example/src/screens/IMessageScreen.tsx @@ -6,7 +6,6 @@ import { type ComponentRef, } from 'react'; import { - Keyboard, KeyboardAvoidingView, Platform, Pressable, @@ -23,25 +22,16 @@ import Bubble, { IM_BG, IM_BLUE, IM_CHROME, - IM_MUTED, } from '../components/imessage/Bubble'; -import { - Chevron, - Lock, - Microphone, - Plus, - VideoCamera, -} from '../components/imessage/glyphs'; -import EffectsSheet from '../components/imessage/EffectsSheet'; +import { Chevron } from '../components/imessage/glyphs'; import ScreenEffectPlayer from '../components/imessage/ScreenEffectPlayer'; import { EFFECT_LABEL, isScreenEffect, type BubbleTarget, - type EffectId, + type ScreenEffectId, } from '../components/imessage/types'; import { - DEFAULT_TEXT, DEMO_ORDER, forgetTarget, reportTarget, @@ -49,20 +39,11 @@ import { useIMessageEffects, } from '../hooks/useIMessageEffects'; -// iMessage clone, dark appearance, laid out from a real Messages thread: -// circular back and video buttons flank a large avatar over the name pill, -// the conversation opens with the iMessage / Encrypted / date block, and the -// composer is a translucent pill whose mic becomes the send arrow once there -// is text. Sending can carry a screen effect (echo, spotlight, confetti, -// fireworks, lasers) that plays once over the chat. -// The demo is scripted: it opens with one text from Kacper, each effect -// button posts the next line with that effect and Kacper answers a beat -// later. Long-press the send arrow for the full effects sheet. +// A made-up chat. Each chip sends the draft with its screen effect. With an +// empty draft it posts the next scripted line, and Noa answers a beat later. -const CONTACT = 'Kacper'; -const STARTED = '12 Sep 2026 at 09:41'; -// Header: avatar + gap + name pill, measured from the reference. -const HEADER_H = 60 + 6 + 30; +const CONTACT = 'Noa'; +const HEADER_H = 56; export default function IMessageScreen() { const insets = useSafeAreaInsets(); @@ -70,7 +51,6 @@ export default function IMessageScreen() { const { messages, playing, busy, send, finishPlaying, getTarget } = useIMessageEffects(); const [draft, setDraft] = useState(''); - const [sheet, setSheet] = useState(null); const scroll = useRef>(null); const messagesRef = useRef(messages); messagesRef.current = messages; @@ -101,21 +81,12 @@ export default function IMessageScreen() { setDraft(''); }, [draft, send]); - const openSheet = useCallback(() => { - // The sheet needs the whole screen; the keyboard would cover its preview. - Keyboard.dismiss(); - setSheet(draft.trim() || DEFAULT_TEXT.none); - }, [draft]); - - const sendWith = useCallback( - (effect: EffectId) => { - if (sheet !== null) { - send(effect, sheet); - } - setSheet(null); + const sendWithEffect = useCallback( + (effect: ScreenEffectId) => { + send(effect, draft); setDraft(''); }, - [send, sheet] + [draft, send] ); const onTargetFor = useCallback( @@ -134,7 +105,6 @@ export default function IMessageScreen() { /> ) : null; - const lastMine = [...messages].reverse().find((m) => m.from === 'me'); const usedEffects = new Set( messages.filter((m) => m.from === 'me').map((m) => m.effect) ); @@ -151,19 +121,11 @@ export default function IMessageScreen() { style={styles.thread} contentContainerStyle={[ styles.threadContent, - { paddingTop: insets.top + HEADER_H + 12 }, + { paddingTop: insets.top + HEADER_H + 16 }, ]} showsVerticalScrollIndicator={false} keyboardDismissMode="interactive" > - - iMessage - - - Encrypted - - {STARTED} - {messages.map((m, i) => { const next = messages[i + 1]; const tail = !next || next.from !== m.from; @@ -178,11 +140,7 @@ export default function IMessageScreen() { onTarget={ isScreenEffect(m.effect) ? onTargetFor(m.id) : undefined } - wantSnapshot={m.effect === 'echo'} /> - {lastMine?.id === m.id && ( - Delivered - )} ); })} @@ -191,28 +149,16 @@ export default function IMessageScreen() { navigation.goBack()} - style={styles.circle} + style={styles.backButton} accessibilityRole="button" accessibilityLabel="Back" > - - - {CONTACT[0]} - - - {CONTACT} - - - - - + + {CONTACT[0]} + {CONTACT} send(e)} + onPress={() => sendWithEffect(e)} disabled={busy} style={({ pressed }) => [ styles.effectChip, @@ -256,15 +202,12 @@ export default function IMessageScreen() { })} - - - ↑ - ) : ( - - - - )} + ) : null} {player} - - {sheet !== null && ( - setSheet(null)} - onSend={sendWith} - /> - )} ); } @@ -317,37 +245,9 @@ const styles = StyleSheet.create({ justifyContent: 'flex-end', paddingBottom: 8, }, - intro: { - alignItems: 'center', - gap: 2, - marginBottom: 14, - }, - introRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - }, - introText: { - color: IM_MUTED, - fontSize: 14, - }, - introDate: { - color: IM_MUTED, - fontSize: 12, - fontWeight: '600', - marginTop: 18, - }, runGap: { marginBottom: 4, }, - delivered: { - alignSelf: 'flex-end', - color: IM_MUTED, - fontSize: 11, - fontWeight: '600', - marginRight: 18, - marginTop: 3, - }, header: { position: 'absolute', @@ -355,48 +255,31 @@ const styles = StyleSheet.create({ left: 0, right: 0, flexDirection: 'row', - alignItems: 'flex-start', - justifyContent: 'space-between', - paddingHorizontal: 15, - backgroundColor: IM_BG, - }, - circle: { - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: IM_CHROME, alignItems: 'center', - justifyContent: 'center', - marginTop: 8, + gap: 10, + paddingHorizontal: 8, + paddingBottom: 8, + backgroundColor: IM_CHROME, }, - contact: { + backButton: { + width: 40, + height: 48, alignItems: 'center', - gap: 6, - paddingBottom: 6, + justifyContent: 'center', }, avatar: { - width: 60, - height: 60, - borderRadius: 30, - backgroundColor: '#4A465D', + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: IM_BLUE, alignItems: 'center', justifyContent: 'center', }, avatarText: { color: '#fff', - fontSize: 27, + fontSize: 16, fontWeight: '700', }, - namePill: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - height: 30, - paddingLeft: 14, - paddingRight: 12, - borderRadius: 15, - backgroundColor: IM_CHROME, - }, contactName: { color: '#fff', fontSize: 17, @@ -406,7 +289,6 @@ const styles = StyleSheet.create({ bottom: { backgroundColor: IM_BG, }, - // One row, in the order the demo means them to be pressed. effectRow: { flexDirection: 'row', justifyContent: 'center', @@ -424,15 +306,15 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(255,255,255,0.06)', }, effectChipUsed: { - borderColor: 'rgba(76,149,247,0.45)', - backgroundColor: 'rgba(76,149,247,0.12)', + borderColor: 'rgba(45,125,106,0.6)', + backgroundColor: 'rgba(45,125,106,0.2)', }, effectChipPressed: { borderColor: IM_BLUE, backgroundColor: IM_BLUE, transform: [{ scale: 0.94 }], }, - // Kacper is typing: hold the row until his reply lands. + // Noa is typing: hold the row until the reply lands. effectChipBusy: { opacity: 0.4, }, @@ -442,7 +324,7 @@ const styles = StyleSheet.create({ fontWeight: '600', }, effectChipTextUsed: { - color: IM_BLUE, + color: '#8FD3C1', }, effectChipTextPressed: { color: '#fff', @@ -456,24 +338,14 @@ const styles = StyleSheet.create({ paddingRight: 16, paddingTop: 6, }, - plus: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: IM_CHROME, - alignItems: 'center', - justifyContent: 'center', - }, inputWrap: { flex: 1, minHeight: 40, - borderRadius: 20, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.14)', - backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: 10, + backgroundColor: IM_CHROME, flexDirection: 'row', alignItems: 'flex-end', - paddingLeft: 18, + paddingLeft: 12, paddingRight: 6, paddingVertical: 3, }, @@ -485,17 +357,10 @@ const styles = StyleSheet.create({ paddingTop: 7, paddingBottom: 7, }, - micWrap: { - width: 32, - height: 32, - alignItems: 'center', - justifyContent: 'center', - marginBottom: 1, - }, send: { width: 32, height: 32, - borderRadius: 16, + borderRadius: 8, backgroundColor: IM_BLUE, alignItems: 'center', justifyContent: 'center', diff --git a/example/src/screens/LinearGradient/LinearGradientAnimatedScreen.tsx b/example/src/screens/LinearGradient/LinearGradientAnimatedScreen.tsx deleted file mode 100644 index dc00f1f..0000000 --- a/example/src/screens/LinearGradient/LinearGradientAnimatedScreen.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { - View, - Text, - StyleSheet, - TouchableOpacity, - StatusBar, -} from 'react-native'; -import { LinearGradient } from 'react-native-effects'; -import { useState, useCallback } from 'react'; - -type ColorScheme = { - name: string; - startColor: string; - endColor: string; -}; - -const COLOR_SCHEMES: ColorScheme[] = [ - { name: 'Sunset', startColor: '#ff6b6b', endColor: '#ffd93d' }, - { name: 'Ocean', startColor: '#667eea', endColor: '#764ba2' }, - { name: 'Forest', startColor: '#56ab2f', endColor: '#a8e063' }, - { name: 'Fire', startColor: '#f83600', endColor: '#f9d423' }, - { name: 'Midnight', startColor: '#2c3e50', endColor: '#4ca1af' }, -]; - -export default function LinearGradientAnimatedScreen() { - const [currentSchemeIndex, setCurrentSchemeIndex] = useState(0); - - const currentScheme = COLOR_SCHEMES[currentSchemeIndex]!; - - const handleNextScheme = useCallback(() => { - setCurrentSchemeIndex((prev) => (prev + 1) % COLOR_SCHEMES.length); - }, []); - - const handlePreviousScheme = useCallback(() => { - setCurrentSchemeIndex( - (prev) => (prev - 1 + COLOR_SCHEMES.length) % COLOR_SCHEMES.length - ); - }, []); - - return ( - - - - - - - - {currentScheme.name} - - - - - Previous - - - Next - - - - - - - {currentScheme.startColor} - - - - {currentScheme.endColor} - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - 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)', - marginBottom: 40, - }, - schemeName: { - fontSize: 24, - fontWeight: '700', - color: '#fff', - textAlign: 'center', - letterSpacing: -0.3, - }, - controls: { - flexDirection: 'row', - gap: 16, - marginBottom: 40, - }, - controlButton: { - backgroundColor: 'rgba(255, 255, 255, 0.2)', - paddingHorizontal: 28, - paddingVertical: 14, - borderRadius: 20, - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.3)', - }, - controlButtonText: { - color: '#fff', - fontSize: 16, - fontWeight: '600', - }, - colorInfo: { - flexDirection: 'row', - gap: 24, - }, - colorRow: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.4)', - paddingHorizontal: 20, - paddingVertical: 12, - borderRadius: 16, - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.2)', - }, - colorDot: { - width: 24, - height: 24, - borderRadius: 12, - marginRight: 12, - borderWidth: 2, - borderColor: 'rgba(255, 255, 255, 0.5)', - }, - colorText: { - fontSize: 14, - fontFamily: 'monospace', - color: '#fff', - fontWeight: '600', - }, -}); diff --git a/example/src/screens/LinearGradient/LinearGradientListScreen.tsx b/example/src/screens/LinearGradient/LinearGradientListScreen.tsx deleted file mode 100644 index 70eece7..0000000 --- a/example/src/screens/LinearGradient/LinearGradientListScreen.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { ScrollView, StyleSheet } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { EffectRow } from '../../components/catalog/EffectRow'; -import type { Effect } from '../../catalog/effects'; -import { colors } from '../../catalog/theme'; -import type { HomeScreenNavigationProp } from '../../types'; - -const EXAMPLES: Effect[] = [ - { - id: 'static', - title: 'Static', - line: 'Presets at different angles', - screen: 'LinearGradientStatic', - image: require('../../../assets/catalog/linear-gradient.jpg'), - }, - { - id: 'animated', - title: 'Animated', - line: 'Colors that rotate continuously', - screen: 'LinearGradientAnimated', - image: require('../../../assets/catalog/linear-gradient-animated.jpg'), - }, -]; - -export default function LinearGradientListScreen() { - const navigation = useNavigation(); - - return ( - - {EXAMPLES.map((example, index) => ( - navigation.navigate(effect.screen as never)} - showSeparator={index < EXAMPLES.length - 1} - /> - ))} - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background, - }, - content: { - paddingTop: 8, - }, -}); diff --git a/example/src/screens/LinearGradient/LinearGradientStaticScreen.tsx b/example/src/screens/LinearGradient/LinearGradientStaticScreen.tsx deleted file mode 100644 index 1a7d4f4..0000000 --- a/example/src/screens/LinearGradient/LinearGradientStaticScreen.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { - View, - Text, - StyleSheet, - ScrollView, - StatusBar, - Dimensions, -} from 'react-native'; -import { LinearGradient } from 'react-native-effects'; - -const { width } = Dimensions.get('window'); -const CARD_WIDTH = width - 80; - -type GradientConfig = { - id: string; - name: string; - startColor: string; - endColor: string; - angle: number; -}; - -const GRADIENT_PRESETS: GradientConfig[] = [ - { - id: 'sunset', - name: 'Sunset', - startColor: '#ff6b6b', - endColor: '#ffd93d', - angle: 45, - }, - { - id: 'ocean', - name: 'Ocean', - startColor: '#667eea', - endColor: '#764ba2', - angle: 135, - }, - { - id: 'forest', - name: 'Forest', - startColor: '#56ab2f', - endColor: '#a8e063', - angle: 90, - }, - { - id: 'fire', - name: 'Fire', - startColor: '#f83600', - endColor: '#f9d423', - angle: 180, - }, - { - id: 'midnight', - name: 'Midnight', - startColor: '#2c3e50', - endColor: '#4ca1af', - angle: 0, - }, - { - id: 'candy', - name: 'Candy', - startColor: '#fc5c7d', - endColor: '#6a82fb', - angle: 225, - }, - { - id: 'peach', - name: 'Peach', - startColor: '#ffecd2', - endColor: '#fcb69f', - angle: 270, - }, - { - id: 'lavender', - name: 'Lavender', - startColor: '#c471f5', - endColor: '#fa71cd', - angle: 315, - }, - { - id: 'emerald', - name: 'Emerald', - startColor: '#00c6ff', - endColor: '#0072ff', - angle: 45, - }, - { - id: 'rose', - name: 'Rose', - startColor: '#ed4264', - endColor: '#ffedbc', - angle: 135, - }, -]; - -export default function LinearGradientStaticScreen() { - return ( - - - - - {GRADIENT_PRESETS.map((preset) => ( - - - - - - {preset.name} - - - - {preset.startColor} - - - - {preset.endColor} - - - Angle: {preset.angle}° - - - ))} - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#000', - }, - scrollView: { - flex: 1, - }, - scrollContent: { - padding: 40, - paddingTop: 16, - paddingBottom: 80, - alignItems: 'center', - }, - gradientCard: { - width: CARD_WIDTH, - marginBottom: 32, - backgroundColor: '#111', - borderRadius: 24, - overflow: 'hidden', - borderWidth: 1, - borderColor: '#252525', - }, - gradientWrapper: { - width: '100%', - height: 240, - overflow: 'hidden', - }, - gradient: { - width: '100%', - height: '100%', - }, - gradientInfo: { - padding: 24, - }, - gradientName: { - fontSize: 24, - fontWeight: '700', - color: '#fff', - marginBottom: 16, - letterSpacing: -0.5, - }, - colorInfo: { - flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: 12, - }, - colorSwatch: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - marginRight: 12, - }, - colorDot: { - width: 20, - height: 20, - borderRadius: 10, - marginRight: 10, - borderWidth: 2, - borderColor: '#333', - }, - colorText: { - fontSize: 13, - fontFamily: 'monospace', - color: '#aaa', - fontWeight: '500', - }, - angleText: { - fontSize: 14, - color: '#777', - fontWeight: '600', - marginTop: 4, - }, -}); diff --git a/example/src/screens/Magic8BallScreen.tsx b/example/src/screens/Magic8BallScreen.tsx index 459cdab..b186956 100644 --- a/example/src/screens/Magic8BallScreen.tsx +++ b/example/src/screens/Magic8BallScreen.tsx @@ -16,49 +16,20 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Magic8Ball from '../components/magic8ball/Magic8Ball'; import { useMagic8BallPhysics } from '../hooks/useMagic8BallPhysics'; -/** Ball radius as a fraction of the screen height, and of its width. */ +/** Orb radius as a fraction of the screen height, and of its width. */ const RADIUS_H = 0.25; const RADIUS_W = 0.47; -/** Where the ball's centre sits, in screen uv (y-up). */ -const CENTER_Y = 0.5; -/** The window, in ball radii. */ -const WINDOW_R = 0.46; -const WINDOW_Y = -0.04; +/** Where the orb's centre sits, in screen uv (y-up). */ +const CENTER_Y = 0.52; +/** The clearing the answer shows through, in orb radii. */ +const REVEAL_R = 0.6; +const REVEAL_Y = 0; -/** - * The answer text block, in window radii. The die hangs point-down, so its - * width falls away toward the bottom: the text has to live in the upper third, - * where there is room for a phrase, and stay narrow enough not to poke out - * through the sloping sides. - * - * TEXT_WIDTH is what makes that true, and it is deliberately narrower than the - * die's top edge. The box is a rectangle over a triangle, so it has to be cut - * to the width the triangle still has at the BOTTOM line of the block, not at - * the top — sized to the top edge, a second line runs out through both sloping - * sides. Nothing measures at runtime: the answers carry their own line breaks, - * and these four numbers are chosen so the longest of them fits. - */ -const TEXT_WIDTH = 0.84; -const TEXT_TOP = 0.33; -const TEXT_HEIGHT = 0.44; -const FONT_SCALE = 0.165; +/** The answer text block, in reveal radii. */ +const TEXT_WIDTH = 1.5; +const TEXT_HEIGHT = 0.72; +const FONT_SCALE = 0.22; -/** - * The classic twenty, said short — and broken into lines HERE, by hand. - * - * Two constraints, and they fight. The answer is printed on a triangle that - * narrows as it goes down, so a line has room for about six characters at a - * size anyone can read. And React Native's own answer to that, - * `adjustsFontSizeToFit`, does not wrap a phrase that is too wide — it shrinks - * it, past `minimumFontScale`, so NO CHANCE came out two thirds the size of - * LIKELY and the screen looked broken. It also hides the real capacity from - * you while you tune, because everything appears to fit. - * - * So the line breaks are data. Every line below is six characters or fewer, - * every answer is at most two lines, and the font is fixed: nothing measures, - * nothing shrinks, and every answer is exactly as big as every other one. - * Adding an answer means counting its characters. - */ const ANSWERS = [ 'YES', 'YES\nINDEED', @@ -68,7 +39,7 @@ const ANSWERS = [ 'LIKELY', 'LOOKS\nGOOD', 'GOOD\nSIGNS', - 'REPLY\nHAZY', + 'ALL\nHAZY', 'ASK\nLATER', 'ASK\nAGAIN', "CAN'T\nSAY", @@ -83,14 +54,12 @@ const ANSWERS = [ ]; /** - * A Magic 8-ball. + * A mystic glass orb. * - * Shake the phone and the fluid churns, dragging the answer off the window and - * back into the ink. Once it settles, buoyancy floats a new one up along - * whichever way is really up — so tip the phone and the answer arrives from a - * different corner — and it bumps the glass once before it comes to rest. - * Tapping the ball shakes it too, which is the only way to see any of this on - * a simulator with no accelerometer. + * Shake the phone and the mist closes over the answer. Once it settles, a + * clearing rises along whichever way is really up and a new answer shows + * through. Tapping the orb shakes it too, for a simulator with no + * accelerometer. */ export default function Magic8BallScreen() { const { width, height } = useWindowDimensions(); @@ -112,51 +81,38 @@ export default function Magic8BallScreen() { const { paramsSynchronizable, shake, rise, drift, spinDeg } = useMagic8BallPhysics({ onSubmerged }); - // Radius is a fraction of the height, but a short wide screen has to cap it - // on width or the ball runs off the sides. + // A short wide screen has to cap the radius on width. const radius = useMemo( () => Math.min(RADIUS_H, (RADIUS_W * width) / height), [width, height] ); const params = useMemo( - () => [0.5, CENTER_Y, radius, WINDOW_R, WINDOW_Y, 0, 0, 0], + () => [0.5, CENTER_Y, radius, REVEAL_R, REVEAL_Y, 0, 0, 0], [radius] ); - // The text has to land on a die the shader placed, so it is positioned by - // the same numbers the shader gets: ball radius in points, then the window - // centre and radius in ball radii. + // The text has to land on the clearing the shader opens, so it is placed by + // the same numbers. const geom = useMemo(() => { const radPt = radius * height; - const wrPt = WINDOW_R * radPt; + const wrPt = REVEAL_R * radPt; return { wrPt, centerX: 0.5 * width, - centerY: (1 - (CENTER_Y + WINDOW_Y * radius)) * height, - // From the middle of the text box down to the middle of the window, - // which is the point the shader turns and scales the die about. - dieOriginDy: (TEXT_TOP - TEXT_HEIGHT / 2) * wrPt, + centerY: (1 - (CENTER_Y + REVEAL_Y * radius)) * height, }; }, [radius, width, height]); const answerStyle = useAnimatedStyle(() => { const r = rise.value; return { - // Deep in the ink the shader shows a blurred triangle and no text at - // all; text only makes sense once the die is close to the glass. opacity: interpolate(r, [0.55, 0.95], [0, 1], Extrapolation.CLAMP), transform: [ { translateX: drift.value.x * geom.wrPt }, { translateY: drift.value.y * geom.wrPt }, - // The shader turns and scales the die about the middle of the window, - // not about this box — so step the origin down there, turn, scale, and - // step back. Rotating about the text's own centre instead would slide - // the words off the face they are printed on. - { translateY: geom.dieOriginDy }, { rotate: `${spinDeg.value}deg` }, { scale: interpolate(r, [0.55, 1], [0.92, 1], Extrapolation.CLAMP) }, - { translateY: -geom.dieOriginDy }, ], }; }); @@ -177,7 +133,7 @@ export default function Magic8BallScreen() { { width: TEXT_WIDTH * geom.wrPt, left: geom.centerX - (TEXT_WIDTH * geom.wrPt) / 2, - top: geom.centerY - TEXT_TOP * geom.wrPt, + top: geom.centerY - (TEXT_HEIGHT / 2) * geom.wrPt, height: TEXT_HEIGHT * geom.wrPt, }, answerStyle, @@ -198,18 +154,13 @@ export default function Magic8BallScreen() { - {/* Tapping the ball shakes it. A sibling rather than a wrapper, so the - back button stays its own element instead of being swallowed into - one accessibility node. */} shake(1)} accessibilityRole="button" - accessibilityLabel="Shake the ball" + accessibilityLabel="Shake the orb" /> - {/* 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 @@ -220,7 +171,7 @@ export default function Magic8BallScreen() { const styles = StyleSheet.create({ root: { flex: 1, - backgroundColor: '#08090c', + backgroundColor: '#050a0b', }, answerBox: { position: 'absolute', @@ -228,24 +179,21 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, answer: { - color: '#fff', - fontWeight: '800', - letterSpacing: 0.6, + color: '#fff6e0', + fontWeight: '700', + letterSpacing: 1, textAlign: 'center', - // The lettering on a real answer die is raised, and the shader's key comes - // from up and to the left — so it casts down and to the right. - textShadowColor: 'rgba(2, 6, 26, 0.85)', - textShadowOffset: { width: 1, height: 1.2 }, - textShadowRadius: 1.5, + textShadowColor: 'rgba(255, 214, 140, 0.75)', + textShadowOffset: { width: 0, height: 0 }, + textShadowRadius: 10, }, caption: { position: 'absolute', - // Never eat a tap meant for the ball underneath. pointerEvents: 'none', left: 0, paddingHorizontal: 32, textAlign: 'center', - color: 'rgba(220, 232, 255, 0.55)', + color: 'rgba(226, 240, 234, 0.62)', fontSize: 14, fontWeight: '600', letterSpacing: 0.4, diff --git a/example/src/screens/NameDropScreen.tsx b/example/src/screens/NameDropScreen.tsx index 29a993e..54d0277 100644 --- a/example/src/screens/NameDropScreen.tsx +++ b/example/src/screens/NameDropScreen.tsx @@ -18,20 +18,18 @@ import Animated, { import { scheduleOnRN } from 'react-native-worklets'; import { captureRef } from 'react-native-view-shot'; import { useParamsSynchronizable } from 'react-native-effects'; -import NameDropWave, { HOME_ATLAS } from '../components/namedrop/NameDropWave'; +import NameDropWave, { SCENE_ATLAS } from '../components/namedrop/NameDropWave'; import PosterReveal from '../components/namedrop/PosterReveal'; -import ContactPoster, { - type ExchangeChoice, +import DuskPoster, { type PosterHandle, -} from '../components/namedrop/ContactPoster'; +} from '../components/namedrop/DuskPoster'; -/** Downward drag (pt) that counts as bringing the other phone close. */ +/** Downward drag (pt) that starts the wave. */ const TRIGGER_DRAG = 70; -/** Seconds from contact until the poster is fully on screen. */ +/** Seconds from the start until the poster is fully on screen. */ const REVEAL_SECONDS = 2.42; -const RESET_AFTER_CHOICE_MS = 1600; -type Phase = 'home' | 'exchanging' | 'poster'; +type Phase = 'home' | 'wave' | 'poster'; type NameDropDevHooks = { __nameDrop?: () => void; @@ -51,19 +49,16 @@ async function snapshotPoster(view: PosterHandle) { } /** - * 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. + * An animation over a misty morning scene: drag down from the top (or tap) and + * a coloured glow climbs the screen and pools under the cut-out, a liquid wave + * of light bursts out of it, and a 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); @@ -78,8 +73,6 @@ export default function NameDropScreen() { const stopClock = useCallback(() => { cancelAnimation(clock); isPlaying.current = false; - if (resetTimer.current !== null) clearTimeout(resetTimer.current); - resetTimer.current = null; }, [clock]); useEffect(() => stopClock, [stopClock]); @@ -92,8 +85,7 @@ export default function NameDropScreen() { const play = useCallback(() => { stopClock(); isPlaying.current = true; - setChoice(null); - setPhase('exchanging'); + setPhase('wave'); cover.value = 1; clock.value = 0; clock.value = withTiming( @@ -117,22 +109,14 @@ export default function NameDropScreen() { 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)); + .catch((e) => console.warn('[Wave Reveal] poster snapshot failed:', e)); }, []); - // `__nameDrop()` plays the exchange, `__nameDropWaveAt(seconds)` freezes + // `__nameDrop()` plays the wave, `__nameDropWaveAt(seconds)` freezes // it at one moment and `__nameDropSpeed(factor | null)` slows it down. useEffect(() => { if (!__DEV__) return; @@ -142,7 +126,7 @@ export default function NameDropScreen() { stopClock(); clock.value = seconds; const done = seconds >= REVEAL_SECONDS; - setPhase(done ? 'poster' : 'exchanging'); + setPhase(done ? 'poster' : 'wave'); cover.value = done ? 0 : 1; }; g.__nameDropSpeed = (factor) => { @@ -177,13 +161,11 @@ export default function NameDropScreen() { onPress={goHome} disabled={phase !== 'poster'} > - @@ -193,7 +175,7 @@ export default function NameDropScreen() { > diff --git a/example/src/screens/PolaroidScreen.tsx b/example/src/screens/PolaroidScreen.tsx index 45af598..0d98b64 100644 --- a/example/src/screens/PolaroidScreen.tsx +++ b/example/src/screens/PolaroidScreen.tsx @@ -44,11 +44,11 @@ import { usePolaroidShake } from '../hooks/usePolaroidShake'; * mountains: "Mountain lake, Mylgval mountain cirque before sunset, * Caucasus Mountains" by Vyacheslav Argenberg (CC BY 4.0). */ -const TABLE = require('../../assets/polaroid/table.jpg'); +const TABLE = require('../../assets/camera/table.jpg'); const PHOTOS = [ - require('../../assets/polaroid/photo-beach.jpg'), - require('../../assets/polaroid/photo-lake.jpg'), - require('../../assets/polaroid/photo-mountains.jpg'), + require('../../assets/camera/photo-beach.jpg'), + require('../../assets/camera/photo-lake.jpg'), + require('../../assets/camera/photo-mountains.jpg'), ]; /** Real prints are readable in ~2 min and done in ~15; this is the demo cut. */ @@ -218,7 +218,7 @@ function Print({ } /** - * Polaroid instant print. + * An 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 diff --git a/example/src/screens/ScratchOffScreen.tsx b/example/src/screens/ScratchOffScreen.tsx index 8ae6be9..feaad8e 100644 --- a/example/src/screens/ScratchOffScreen.tsx +++ b/example/src/screens/ScratchOffScreen.tsx @@ -18,14 +18,11 @@ import ScratchCoating, { } from '../components/scratchoff/ScratchCoating'; import { useScratchCard } from '../hooks/useScratchCard'; -const PRIZES = ['$5', '$20', '$50', '$500']; +const SYMBOL_NAMES = ['stars', 'clovers', 'diamonds', 'hearts'] as const; type Ticket = { - symbols: readonly [PrizeSymbol, PrizeSymbol, PrizeSymbol, PrizeSymbol]; - amounts: string[]; - bonus: string; + symbols: readonly [PrizeSymbol, PrizeSymbol, PrizeSymbol]; serial: string; - win: boolean; }; function pick(list: readonly T[]): T { @@ -33,22 +30,13 @@ function pick(list: readonly T[]): T { } 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']), + symbols: [main, main, main], serial, - win, }; } @@ -91,13 +79,8 @@ export default function ScratchOffScreen() { - Clover Cash - - Match three symbols, win that prize. - - - - $5 + Lucky Clover + Find three matching symbols. @@ -109,13 +92,8 @@ export default function ScratchOffScreen() { style={[ styles.panel, { left: a.x, top: a.y, width: a.w, height: a.h }, - i === 3 && styles.bonusPanel, ]} - > - - {i === 3 ? ticket.bonus : ticket.amounts[i]} - - + /> ))} No. {ticket.serial} - Void if altered + Just for fun - {ticket.win ? `You won ${ticket.amounts[0]}` : 'Not this time'} + You found three {SYMBOL_NAMES[ticket.symbols[0]]} ) => { - const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; - const y = contentOffset.y; - const max = Math.max(1, contentSize.height - layoutMeasurement.height); - const progress = Math.min(1, Math.max(0, y / max)); - // Rubber-band overscroll past either edge, normalized to screen height: - // negative above the top, positive below the bottom, 0 while in range. - const h = Math.max(1, layoutMeasurement.height); - const over = y < 0 ? y / h : y > max ? (y - max) / h : 0; - - // Push to the shader without touching React state. - setParamsSynchronizable(progress, over, 0, 0); - }, - [setParamsSynchronizable] - ); - - return ( - - - - {/* Faint scrim to lift text contrast without hiding the shader. */} - - - - - - {/* Masthead */} - Scroll Reactive - - A live WebGPU contour map, driven entirely by the scroll. - - - {/* Editorial index */} - {ENTRIES.map((text, i) => { - const isLast = i === ENTRIES.length - 1; - return ( - - - - {isLast ? ( - fin - ) : ( - {i + 1} - )} - - {text} - - - - ); - })} - - - ); -} - -const GOLD = '#a7f3d0'; - -// Soft black halo so type stays readable over the live shader behind it. -const TEXT_SHADOW = { - textShadowColor: 'rgba(0, 0, 0, 0.95)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 12, -} as const; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#000', - }, - scrim: { - backgroundColor: 'rgba(0, 0, 0, 0.32)', - }, - scroll: { - flex: 1, - }, - scrollContent: { - paddingHorizontal: 26, - }, - - /* Masthead */ - title: { - fontFamily: SERIF, - fontSize: 38, - lineHeight: 42, - color: '#fff', - letterSpacing: -0.5, - ...TEXT_SHADOW, - }, - deck: { - fontFamily: SERIF, - fontSize: 17, - lineHeight: 26, - color: 'rgba(255, 255, 255, 0.95)', - marginTop: 20, - marginBottom: 26, - maxWidth: 300, - ...TEXT_SHADOW, - }, - - /* Index entries */ - entry: { - paddingVertical: 2, - }, - rule: { - height: 1.5, - backgroundColor: 'rgba(167, 243, 208, 0.5)', - }, - entryRow: { - flexDirection: 'row', - alignItems: 'flex-start', - paddingVertical: 30, - }, - numeral: { - width: 56, - fontFamily: SERIF, - fontSize: 30, - lineHeight: 34, - color: GOLD, - ...TEXT_SHADOW, - }, - fin: { - width: 56, - fontFamily: SERIF, - fontStyle: 'italic', - fontSize: 19, - lineHeight: 34, - color: 'rgba(167, 243, 208, 0.7)', - ...TEXT_SHADOW, - }, - headline: { - flex: 1, - fontFamily: SERIF, - fontSize: 25, - lineHeight: 34, - color: '#fff', - paddingTop: 1, - ...TEXT_SHADOW, - }, - headlineFin: { - fontStyle: 'italic', - color: 'rgba(255, 255, 255, 0.82)', - }, - - /* Folio */ - folioWrap: { - position: 'absolute', - right: 26, - zIndex: 90, - }, - folio: { - fontFamily: SERIF, - fontSize: 15, - color: 'rgba(255, 255, 255, 0.72)', - letterSpacing: 1, - }, - folioDim: { - color: 'rgba(255, 255, 255, 0.4)', - }, -}); diff --git a/example/src/screens/SiriOrbScreen.tsx b/example/src/screens/SiriOrbScreen.tsx index 0ea4051..1626372 100644 --- a/example/src/screens/SiriOrbScreen.tsx +++ b/example/src/screens/SiriOrbScreen.tsx @@ -1,25 +1,20 @@ import { View, StyleSheet, StatusBar, Image } from 'react-native'; import SiriOrb from '../components/SiriOrb'; -import SiriEdgeGlow from '../components/SiriEdgeGlow'; export default function SiriOrbScreen() { return ( - - - + - - ); } @@ -28,9 +23,8 @@ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#000', - }, - spacer: { - flex: 1, + alignItems: 'center', + justifyContent: 'center', }, backgroundImage: { ...StyleSheet.absoluteFill, @@ -39,8 +33,10 @@ const styles = StyleSheet.create({ opacity: 0.9, }, orbWrapper: { - alignSelf: 'center', - width: 200, - height: 200, + width: 320, + height: 320, + }, + orb: { + flex: 1, }, }); diff --git a/example/src/screens/TelegramDissolveScreen.tsx b/example/src/screens/TelegramDissolveScreen.tsx index 535646b..08214d4 100644 --- a/example/src/screens/TelegramDissolveScreen.tsx +++ b/example/src/screens/TelegramDissolveScreen.tsx @@ -37,7 +37,6 @@ import ParticleDissolve, { } from '../components/telegram/ParticleDissolve'; import { TG_AVATAR_PHOTO, - TG_BLUE, TG_BUBBLE, TG_ICONS as ICONS, TG_MENU, @@ -47,31 +46,30 @@ import { TG_RED, } from '../components/telegram/icons'; -// Telegram group chat clone (dark theme, purple accent). Long-press a message, -// Delete → "Delete for everyone", and the bubble is snapshotted into a texture -// and dissolved into particles by ParticleDissolve. +// A made-up group chat. Long-press a message, Delete, confirm, and the bubble +// is snapshotted into a texture and dissolved into particles by +// ParticleDissolve. const DISSOLVE_MS = 1150; // sweep + drift, measured off the recording const SWAP_MS = 80; // frames for the canvas to draw before the bubble hides const REMOVE_AT = 0.45; // list reflows while the dust is still drifting // Canvas padding around the bubble so drifting particles are not clipped. const PAD = { left: 170, top: 130, right: 30, bottom: 60 }; -const BUBBLE_RADIUS = 17; -const REACTIONS = ['❤️', '👍', '👎', '🔥', '🥰', '👏', '😁']; +const BUBBLE_RADIUS = 13; const MENU_GAP = 10; // bubble → menu const LIFT_MS = 280; // message rises to meet the menu / settles back // Rows slide into place when a message leaves the list. const LAYOUT = LinearTransition.duration(340).easing( Easing.inOut(Easing.cubic) ); -const MENU_H = 8 * 44 + 2 * 6; +const MENU_H = 3 * 44; type Message = { id: string; text: string; time: string }; const INITIAL_MESSAGES: Message[] = [ - { id: 'm1', text: 'Expo Go is enough for prod, right?', time: '20:56' }, - { id: 'm2', text: 'can we just use Flutter', time: '20:56' }, - { id: 'm3', text: 'wrong chat sorry', time: '20:57' }, + { id: 'm1', text: 'who is bringing the tent?', time: '20:56' }, + { id: 'm2', text: 'i thought you had it', time: '20:56' }, + { id: 'm3', text: 'wrong chat, sorry', time: '20:57' }, ]; function Icon({ @@ -114,8 +112,7 @@ function measure(view: ViewHandle): Promise { } /** - * An outgoing Telegram bubble. `capture()` snapshots the whole bubble (purple - * body + text) to a transparent PNG so the particle pass dissolves it as one. + * An outgoing bubble. `capture()` snapshots the whole bubble (body + text) to a transparent PNG so the particle pass dissolves it as one. */ const Bubble = forwardRef< BubbleHandle, @@ -171,7 +168,6 @@ const Bubble = forwardRef< {msg.text} {msg.time} - @@ -253,7 +249,7 @@ export default function TelegramDissolveScreen() { } ); - // 0 = message at its place in the list, 1 = lifted under the reactions bar. + // 0 = message at its place in the list, 1 = lifted above the menu. // Also drives the dim, so the whole overlay settles back together. const lift = useSharedValue(0); @@ -342,7 +338,7 @@ export default function TelegramDissolveScreen() { if (startTimer.current) { return; } - // Let the message settle back into the list first, like Telegram. + // Let the message settle back into the list first. await closeMenu(); // The warmed-up canvas now draws the intact snapshot exactly over the // real bubble; give it a couple of frames, then hide the real one and run. @@ -372,8 +368,8 @@ export default function TelegramDissolveScreen() { const menuMsg = menu ? messages.find((m) => m.id === menu.id) : undefined; - // Telegram lifts the message so the menu fits under it; mirror that by - // shifting the clone, reactions and menu together. + // Lift the message so the menu fits under it, moving the clone and the menu + // together. // The lift is sized for the full menu so the bubble does not jump when the // shorter confirm sheet replaces it. const menuTop = menu @@ -404,7 +400,7 @@ export default function TelegramDissolveScreen() { @@ -414,18 +410,7 @@ export default function TelegramDissolveScreen() { style={styles.infoCard} onPress={restore} > - You created a group - Groups can have: - {[ - 'Up to 200,000 members', - 'Persistent chat history', - 'Public links such as t.me/title', - 'Admins with different rights', - ].map((line) => ( - - ✓ {line} - - ))} + All clear Tap to bring the messages back ) : ( @@ -433,15 +418,6 @@ export default function TelegramDissolveScreen() { Today - - - Blazej Kustra created the - group "Expo Enjoyers" - - {messages.map((m) => ( navigation.goBack()} accessibilityRole="button" accessibilityLabel="Back" > - - Expo Enjoyers - 2137 members - - - - Add Members - - + + Weekend Trip + 4 people {/* Composer */} - - - + - Message - - - - + Write something + {/* Long-press context menu */} @@ -519,54 +484,26 @@ export default function TelegramDissolveScreen() { {menu.stage === 'menu' ? ( - <> - - - {REACTIONS.map((e) => ( - - {e} - - ))} - - - - - - - - - - - - - - - setMenu({ ...menu, stage: 'confirm' })} - /> - - - + + + + + setMenu({ ...menu, stage: 'confirm' })} + /> - + ) : ( startDissolve(menu.id)} > - Delete for everyone + Delete message - startDissolve(menu.id)} - > - Delete for me + + Keep it @@ -647,22 +581,13 @@ const styles = StyleSheet.create({ }, chip: { alignSelf: 'center', - backgroundColor: 'rgba(32,24,30,0.85)', - borderRadius: 14, - paddingHorizontal: 10, paddingVertical: 5, }, chipText: { - color: '#fff', + color: TG_MUTED, fontSize: 13, fontWeight: '500', }, - chipBold: { - fontWeight: '700', - }, - chipGap: { - marginTop: 6, - }, // Bubble bubbleRow: { @@ -673,13 +598,13 @@ const styles = StyleSheet.create({ bubble: { maxWidth: '78%', borderRadius: BUBBLE_RADIUS, - borderBottomRightRadius: 4, + borderTopRightRadius: 3, overflow: 'visible', }, bubbleBg: { ...StyleSheet.absoluteFill, borderRadius: BUBBLE_RADIUS, - borderBottomRightRadius: 4, + borderTopRightRadius: 3, backgroundColor: TG_BUBBLE, }, bubbleContent: { @@ -716,8 +641,8 @@ const styles = StyleSheet.create({ marginBottom: 'auto', marginTop: 40, width: 300, - backgroundColor: 'rgba(28,22,28,0.92)', - borderRadius: 18, + backgroundColor: TG_MENU, + borderRadius: 13, paddingHorizontal: 18, paddingVertical: 16, }, @@ -728,21 +653,10 @@ const styles = StyleSheet.create({ textAlign: 'center', marginBottom: 8, }, - infoSub: { - color: '#fff', - fontSize: 15, - marginBottom: 6, - }, - infoLine: { - color: '#fff', - fontSize: 15, - lineHeight: 26, - }, infoHint: { color: TG_MUTED, fontSize: 12, textAlign: 'center', - marginTop: 12, }, // Header @@ -751,27 +665,18 @@ const styles = StyleSheet.create({ top: 0, left: 0, right: 0, + backgroundColor: TG_PILL, }, headerRow: { height: 52, flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 12, - }, - circle: { - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: TG_PILL, - alignItems: 'center', - justifyContent: 'center', + gap: 10, + paddingHorizontal: 8, }, - titlePill: { + backButton: { + width: 36, height: 44, - borderRadius: 22, - backgroundColor: TG_PILL, - paddingHorizontal: 30, alignItems: 'center', justifyContent: 'center', }, @@ -786,27 +691,9 @@ const styles = StyleSheet.create({ marginTop: 1, }, avatar: { - width: 44, - height: 44, - borderRadius: 22, - }, - addMembers: { - marginTop: 6, - marginHorizontal: 12, - height: 44, - borderRadius: 22, - backgroundColor: TG_PILL, - alignItems: 'center', - justifyContent: 'center', - }, - addMembersText: { - color: TG_BLUE, - fontSize: 17, - }, - addMembersClose: { - position: 'absolute', - right: 12, - top: 11, + width: 36, + height: 36, + borderRadius: 18, }, // Composer @@ -816,30 +703,23 @@ const styles = StyleSheet.create({ right: 0, bottom: 0, paddingTop: 6, - paddingHorizontal: 8, + paddingHorizontal: 14, flexDirection: 'row', alignItems: 'center', - gap: 8, - backgroundColor: 'rgba(0,0,0,0.6)', - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: 'rgba(255,255,255,0.12)', + gap: 12, + backgroundColor: TG_PILL, }, input: { flex: 1, - height: 44, - borderRadius: 22, - backgroundColor: '#181818', - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.14)', - flexDirection: 'row', - alignItems: 'center', - paddingLeft: 14, - paddingRight: 10, - justifyContent: 'space-between', + height: 40, + borderRadius: 10, + backgroundColor: TG_MENU, + justifyContent: 'center', + paddingHorizontal: 12, }, placeholder: { - color: '#7c7c7c', - fontSize: 17, + color: TG_MUTED, + fontSize: 16, }, // Context menu @@ -850,40 +730,12 @@ const styles = StyleSheet.create({ right: { right: 10, }, - reactions: { - height: 52, - borderRadius: 26, - backgroundColor: '#1d191d', - flexDirection: 'row', - alignItems: 'center', - paddingLeft: 10, - paddingRight: 6, - gap: 4, - }, - reaction: { - fontSize: 26, - width: 38, - textAlign: 'center', - }, - reactionMore: { - width: 34, - height: 34, - borderRadius: 17, - backgroundColor: '#2c282c', - alignItems: 'center', - justifyContent: 'center', - marginLeft: 2, - }, menu: { - width: 236, - borderRadius: 16, + width: 220, + borderRadius: 13, backgroundColor: TG_MENU, overflow: 'hidden', }, - menuGap: { - height: 6, - backgroundColor: '#0b090b', - }, menuItem: { height: 44, flexDirection: 'row', @@ -910,4 +762,8 @@ const styles = StyleSheet.create({ color: TG_RED, fontSize: 17, }, + keepText: { + color: '#fff', + fontSize: 17, + }, }); diff --git a/example/src/screens/TelegramSpoilerScreen.tsx b/example/src/screens/TelegramSpoilerScreen.tsx index f1d5b07..62438e5 100644 --- a/example/src/screens/TelegramSpoilerScreen.tsx +++ b/example/src/screens/TelegramSpoilerScreen.tsx @@ -32,13 +32,12 @@ import { } from '../components/telegram/icons'; import { useSpoiler, useSpoilerDebugHooks } from '../hooks/useSpoiler'; -// Telegram group chat clone (dark theme, purple accent) with hidden text: -// a spoiler span is covered by a cloud of drifting sparks, a tap blows the +// A made-up group chat with hidden text: a spoiler span is covered by a cloud of drifting sparks, a tap blows the // cloud open and shows the real text, and after a few seconds it hides again. -const BUBBLE_RADIUS = 17; -const TG_INCOMING = '#232323'; -const SENDER_COLORS = ['#5BC8FA', '#F5A54A']; +const BUBBLE_RADIUS = 13; +const TG_INCOMING = '#232C2A'; +const SENDER_COLORS = ['#8FD3C1', '#E9C46A']; type Part = string | { spoiler: string }; type Message = { @@ -51,53 +50,47 @@ type Message = { const MESSAGES: Message[] = [ { id: 'm1', - from: { name: 'Mike', color: SENDER_COLORS[1]! }, - parts: ['big news. ', { spoiler: 'callstack just bought margelo' }], + from: { name: 'Ines', color: SENDER_COLORS[1]! }, + parts: ['finished the book. ', { spoiler: 'the lighthouse keeper did it' }], time: '10:41', }, { id: 'm2', - from: { name: 'Marc', color: SENDER_COLORS[0]! }, - parts: ['can confirm. ', { spoiler: 'i work for mike now' }], + from: { name: 'Theo', color: SENDER_COLORS[0]! }, + parts: ['same here. ', { spoiler: 'i cried on the train' }], time: '10:41', }, { id: 'm3', from: 'me', - parts: ['congrats lol. so who is next'], + parts: ['careful, i am only on chapter nine'], time: '10:42', }, { id: 'm4', - from: { name: 'Mike', color: SENDER_COLORS[1]! }, - parts: ['software mansion?'], + from: { name: 'Ines', color: SENDER_COLORS[1]! }, + parts: ['tap at your own risk then'], time: '10:42', }, { id: 'm5', from: 'me', parts: [ - 'not for sale. ', - { - spoiler: - 'someone tried once. we laughed and shipped another reanimated release instead', - }, + 'just tell me one thing. ', + { spoiler: 'does the dog make it to the last page' }, ], time: '10:43', }, { id: 'm6', - from: { name: 'Marc', color: SENDER_COLORS[0]! }, - parts: ['marc, marcin, mike. anyone else noticed'], + from: { name: 'Theo', color: SENDER_COLORS[0]! }, + parts: ['the dog is fine'], time: '10:44', }, { id: 'm7', from: 'me', - parts: [ - 'yep. want to found an rn agency? ', - { spoiler: 'your name has to start with M. no exceptions' }, - ], + parts: ['good. ', { spoiler: 'now i can sleep tonight, thank you both' }], time: '10:44', }, ]; @@ -138,7 +131,7 @@ function Body({ mode: SpanMode; onSpoilerPress?: (e: GestureResponderEvent) => void; }) { - // Only the visible copy carries the time and checks; the other two just + // Only the visible copy carries the time; the other two just // reserve the same space so the text wraps identically. const metaInvisible = mode !== 'hidden'; return ( @@ -179,9 +172,6 @@ function Body({ {msg.time} - {msg.from === 'me' && ( - - )} ); @@ -342,32 +332,27 @@ export default function TelegramSpoilerScreen() { navigation.goBack()} accessibilityRole="button" accessibilityLabel="Back" > - - RN Agencies (unofficial) - 3 members - + + Book Club + 3 people + - - - + - Message - - - - + Write something + ); @@ -385,16 +370,13 @@ const styles = StyleSheet.create({ }, chip: { alignSelf: 'center', - backgroundColor: 'rgba(32,24,30,0.85)', - borderRadius: 14, - paddingHorizontal: 10, paddingVertical: 5, }, chipGap: { marginTop: 10, }, chipText: { - color: '#fff', + color: TG_MUTED, fontSize: 13, fontWeight: '500', }, @@ -409,26 +391,26 @@ const styles = StyleSheet.create({ justifyContent: 'flex-start', }, // The canvas fills the bubble and is clipped by it, so the burst never - // spills sparks onto the wallpaper; Telegram keeps them inside the bubble. + // spills sparks onto the wallpaper. bubble: { maxWidth: '78%', borderRadius: BUBBLE_RADIUS, - borderBottomRightRadius: 4, + borderTopRightRadius: 3, overflow: 'hidden', }, bubbleIn: { - borderBottomRightRadius: BUBBLE_RADIUS, - borderBottomLeftRadius: 4, + borderTopRightRadius: BUBBLE_RADIUS, + borderTopLeftRadius: 3, }, bubbleBg: { ...StyleSheet.absoluteFill, borderRadius: BUBBLE_RADIUS, - borderBottomRightRadius: 4, + borderTopRightRadius: 3, backgroundColor: TG_BUBBLE, }, bubbleBgIn: { - borderBottomRightRadius: BUBBLE_RADIUS, - borderBottomLeftRadius: 4, + borderTopRightRadius: BUBBLE_RADIUS, + borderTopLeftRadius: 3, backgroundColor: TG_INCOMING, }, bubbleContent: { @@ -479,27 +461,18 @@ const styles = StyleSheet.create({ top: 0, left: 0, right: 0, + backgroundColor: TG_PILL, }, headerRow: { height: 52, flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 12, - }, - circle: { - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: TG_PILL, - alignItems: 'center', - justifyContent: 'center', + gap: 10, + paddingHorizontal: 8, }, - titlePill: { + backButton: { + width: 36, height: 44, - borderRadius: 22, - backgroundColor: TG_PILL, - paddingHorizontal: 24, alignItems: 'center', justifyContent: 'center', }, @@ -514,9 +487,9 @@ const styles = StyleSheet.create({ marginTop: 1, }, avatar: { - width: 44, - height: 44, - borderRadius: 22, + width: 36, + height: 36, + borderRadius: 18, }, // Composer @@ -526,29 +499,22 @@ const styles = StyleSheet.create({ right: 0, bottom: 0, paddingTop: 6, - paddingHorizontal: 8, + paddingHorizontal: 14, flexDirection: 'row', alignItems: 'center', - gap: 8, - backgroundColor: 'rgba(0,0,0,0.6)', - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: 'rgba(255,255,255,0.12)', + gap: 12, + backgroundColor: TG_PILL, }, input: { flex: 1, - height: 44, - borderRadius: 22, - backgroundColor: '#181818', - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.14)', - flexDirection: 'row', - alignItems: 'center', - paddingLeft: 14, - paddingRight: 10, - justifyContent: 'space-between', + height: 40, + borderRadius: 10, + backgroundColor: '#1B2422', + justifyContent: 'center', + paddingHorizontal: 12, }, placeholder: { - color: '#7c7c7c', - fontSize: 17, + color: TG_MUTED, + fontSize: 16, }, }); diff --git a/example/src/screens/ThanosScreen.tsx b/example/src/screens/ThanosScreen.tsx index be558ed..d9bd532 100644 --- a/example/src/screens/ThanosScreen.tsx +++ b/example/src/screens/ThanosScreen.tsx @@ -91,16 +91,16 @@ export default function ThanosScreen() { > - WebGPU + Corner Cafe - react-native-effects - 0451 · SHADER · WORKLET + Coffee Club + 0451 · 2208 · 7316 - STATUS - RENDERED + STAMPS + 8 OF 10 Tap to snap diff --git a/example/src/screens/VoiceDeleteScreen.tsx b/example/src/screens/VoiceDeleteScreen.tsx index 79fcbab..f2aa693 100644 --- a/example/src/screens/VoiceDeleteScreen.tsx +++ b/example/src/screens/VoiceDeleteScreen.tsx @@ -11,22 +11,16 @@ import { useNavigation } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import VoiceComposer from '../components/instagram/VoiceComposer'; import { + IG_AVATAR as AVATAR, IG_BG as BG, + IG_CARD_AVATAR as CARD_AVATAR, + IG_CARD_PHOTO as CARD_IMAGE, IG_ICONS as ICONS, IG_MUTED as MUTED, + IG_POST_PHOTO as POST_IMAGE, IG_SURFACE as SURFACE, } from '../components/instagram/icons'; -// Instagram DM thread clone. The chrome (icons, colours, spacing) mirrors the -// real app; the people, posts and copy are placeholders. - -const AVATAR = { uri: 'https://picsum.photos/seed/rne-dm-avatar/200/200' }; -const POST_IMAGE = { uri: 'https://picsum.photos/seed/rne-dm-post/720/612' }; -const REEL_IMAGE = { uri: 'https://picsum.photos/seed/rne-dm-reel/480/870' }; -const REEL_AVATAR = { - uri: 'https://picsum.photos/seed/rne-dm-reel-av/100/100', -}; - function Icon({ source, size, @@ -82,19 +76,6 @@ function CircleButton({ ); } -function MessageActions({ top }: { top: number }) { - return ( - - - - - - - - - ); -} - export default function VoiceDeleteScreen() { const insets = useSafeAreaInsets(); const navigation = useNavigation(); @@ -111,54 +92,33 @@ export default function VoiceDeleteScreen() { ]} showsVerticalScrollIndicator={false} > - {/* Shared post */} - - - - AI info - - - - reanimated_daily - Tomasz Zawadzki is the goat 🐐 - - - - ❤️ - + + Last light on the ridge trail, about an hour from the car park. + - 16 AUG AT 10:10 + Saturday 10:10 - {/* Shared reel */} - - - - - - - - yarn_ios_enjoyer and the… + + + + + + Morning fog - - What it's like living with someone who runs pod install a lot? - - - - - Seen + Read {/* Header */} @@ -166,7 +126,7 @@ export default function VoiceDeleteScreen() { navigation.goBack()} label="Back" > @@ -174,17 +134,9 @@ export default function VoiceDeleteScreen() { - - Metro Bundler › - - Bundling 99% for 22m + Juno + Back on Monday - - - - - - {/* Fade the thread out under the header. */} @@ -218,179 +170,84 @@ const styles = StyleSheet.create({ justifyContent: 'flex-end', alignItems: 'flex-start', }, - actions: { - marginRight: 9, - alignItems: 'center', - }, - actionCircle: { - width: 30, - height: 30, - borderRadius: 15, - backgroundColor: SURFACE, - alignItems: 'center', - justifyContent: 'center', - }, - - // Post card postCard: { - width: 240, - borderRadius: 16, + width: 248, + borderRadius: 13, + borderTopRightRadius: 3, backgroundColor: SURFACE, - overflow: 'visible', + overflow: 'hidden', }, postImage: { - width: 240, - height: 204, - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - backgroundColor: '#3a4a5a', - }, - aiPill: { - position: 'absolute', - left: 8, - top: 204 - 8 - 30, - height: 30, - paddingLeft: 7, - paddingRight: 10, - borderRadius: 15, - backgroundColor: 'rgba(18,16,14,0.82)', - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - aiPillText: { - color: '#fff', - fontSize: 13, - fontWeight: '600', - }, - postCaption: { - paddingHorizontal: 12, - paddingVertical: 8, - minHeight: 50, - justifyContent: 'center', - borderBottomLeftRadius: 16, - borderBottomRightRadius: 16, + width: 248, + height: 200, }, captionText: { + paddingHorizontal: 12, + paddingVertical: 10, color: '#fff', - fontSize: 14, - lineHeight: 17, - }, - captionUser: { - fontWeight: '700', - }, - reaction: { - position: 'absolute', - left: 5, - bottom: -23, - width: 30, - height: 30, - borderRadius: 15, - backgroundColor: SURFACE, - alignItems: 'center', - justifyContent: 'center', - }, - reactionText: { - fontSize: 13, + fontSize: 15, + lineHeight: 20, }, date: { - marginTop: 42, - marginBottom: 23, + marginTop: 22, + marginBottom: 14, textAlign: 'center', color: MUTED, fontSize: 12, - letterSpacing: 0.2, }, - // Reel card - reelCard: { - width: 160, - height: 289, - borderRadius: 16, + photoCard: { + width: 180, + height: 300, + borderRadius: 13, + borderTopRightRadius: 3, overflow: 'hidden', backgroundColor: SURFACE, }, - reelImage: { + photoImage: { ...StyleSheet.absoluteFill, - width: 160, - height: 289, + width: 180, + height: 300, }, - reelShade: { + photoHeader: { position: 'absolute', - left: 0, - right: 0, - top: 0, - height: 70, - backgroundColor: 'rgba(0,0,0,0.3)', - }, - reelHeader: { - position: 'absolute', - left: 19, - top: 13, + left: 10, + top: 10, right: 10, flexDirection: 'row', - alignItems: 'flex-start', + alignItems: 'center', gap: 8, }, - reelAvatar: { - width: 20, - height: 20, - borderRadius: 10, - backgroundColor: '#555', + photoAvatar: { + width: 24, + height: 24, + borderRadius: 12, }, - reelUser: { + photoUser: { flex: 1, color: '#fff', fontSize: 14, - lineHeight: 17, fontWeight: '600', textShadowColor: 'rgba(0,0,0,0.6)', textShadowRadius: 3, }, - reelCaption: { - position: 'absolute', - left: 10, - right: 10, - top: 55, - color: '#fff', - fontSize: 12, - lineHeight: 15, - fontWeight: '800', - fontStyle: 'italic', - textAlign: 'center', - textShadowColor: '#000', - textShadowRadius: 2, - textShadowOffset: { width: 0, height: 1 }, - }, playButton: { position: 'absolute', - left: 60, - top: 125, + left: 70, + top: 130, width: 40, height: 40, borderRadius: 20, - backgroundColor: 'rgba(255,255,255,0.4)', + backgroundColor: 'rgba(0,0,0,0.45)', alignItems: 'center', justifyContent: 'center', paddingLeft: 3, }, - reelBadge: { - position: 'absolute', - left: 14, - bottom: 14, - width: 18, - height: 18, - borderRadius: 5, - backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - paddingLeft: 1, - }, seen: { marginTop: 8, - marginRight: 14, + marginRight: 6, textAlign: 'right', color: MUTED, fontSize: 12, @@ -409,15 +266,14 @@ const styles = StyleSheet.create({ height: 56, flexDirection: 'row', alignItems: 'center', - paddingLeft: 16, + paddingLeft: 6, paddingRight: 12, }, avatar: { width: 36, height: 36, borderRadius: 18, - marginLeft: 11, - backgroundColor: '#444', + marginLeft: 4, }, headerText: { flex: 1, @@ -428,11 +284,6 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '600', }, - chevron: { - color: '#fff', - fontSize: 18, - fontWeight: '400', - }, active: { color: MUTED, fontSize: 13, diff --git a/example/src/screens/WalletPayScreen.tsx b/example/src/screens/WalletPayScreen.tsx index cd70436..089275c 100644 --- a/example/src/screens/WalletPayScreen.tsx +++ b/example/src/screens/WalletPayScreen.tsx @@ -9,44 +9,30 @@ import { } 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 { CardArt } 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; +const GLYPH_SIZE = 44; +const ACCENT = '#7FD6BE'; -/** The flow, in seconds from the double click. */ +/** The reveal, in seconds from the tap. */ 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], + ring: [0.1, 1.5], + spark: [1.5, 1.8], + settle: [2.2, 2.8], + end: 4, } as const; -type Stage = 'idle' | 'auth' | 'reader' | 'done'; +type Stage = 'idle' | 'sweep' | 'revealed'; type DebugGlobal = { __walletSeek?: (t: number | null) => void }; @@ -58,85 +44,50 @@ 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', + idle: 'Tap to reveal your card', + sweep: 'Catching the light', + revealed: 'There it is', }; /** - * 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. + * A brushed titanium card under a moving light. Tap anywhere: the card lifts, + * a glint runs over it and a ring draws in the sheet below, then a chime and + * a haptic land 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 { width } = 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 glyph = useParamsSynchronizable([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 cardTop = insets.top + 92; const setCard = card.setParamsSynchronizable; const setGlyph = glyph.setParamsSynchronizable; useEffect(() => { let frame = 0; - let spin = 0; - let last = Date.now(); + let revealed = false; 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 t = + frozenAt.current ?? (t0 === null ? -1 : (Date.now() - t0) / 1000); + const running = t >= 0 && t < FLOW.end; - const glint = running ? ramp(t, [0.05, 0.95]) : 0; + const glint = running ? ramp(t, [0.05, 1.5]) : 0; const up = running ? easeOut(ramp(t, [0, 0.35])) * (1 - ramp(t, FLOW.settle)) : 0; @@ -144,34 +95,24 @@ export default function WalletPayScreen() { 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; + setGlyph(ramp(t, FLOW.ring), ramp(t, FLOW.spark), 0, 0); + if (!revealed && t >= FLOW.spark[0]) { + revealed = true; Vibration.vibrate(40); playChime(); } } else { - setGlyph(0, 0, 0, 0, 0, spin, 0, 0); + setGlyph(0, 0, 0, 0); startedAt.current = null; + revealed = false; } - const next = running ? stageAt(t) : 'idle'; + let next: Stage = 'idle'; + if (running) { + next = t < FLOW.spark[0] ? 'sweep' : 'revealed'; + } if (next !== shown) { shown = next; - if (next === 'auth') { - paid = false; - } setStage(next); } frame = requestAnimationFrame(step); @@ -192,17 +133,13 @@ export default function WalletPayScreen() { }; }, []); - const pay = useCallback(() => { + const reveal = useCallback(() => { if (startedAt.current === null) { startedAt.current = Date.now(); } }, []); - const doubleTap = Gesture.Tap() - .numberOfTaps(2) - .maxDelay(350) - .runOnJS(true) - .onEnd(pay); + const tap = Gesture.Tap().runOnJS(true).onEnd(reveal); const cardStyle = useAnimatedStyle(() => ({ transform: [ @@ -213,31 +150,11 @@ export default function WalletPayScreen() { 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} + Sterling Titanium + + A made-up card for showing off a light effect. Nothing here is + connected to a bank. + + + + + + {STAGE_LABEL[stage]} + ); } -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, @@ -312,45 +214,43 @@ const styles = StyleSheet.create({ borderRadius: CARD_RADIUS, overflow: 'hidden', }, - side: { + sheet: { position: 'absolute', + left: 0, right: 0, - justifyContent: 'center', - }, - sideRow: { - flexDirection: 'row', - alignItems: 'center', + bottom: 0, + paddingTop: 26, + paddingHorizontal: 24, + borderTopLeftRadius: 34, + borderTopRightRadius: 34, + backgroundColor: '#16211E', }, - sideText: { + sheetTitle: { color: '#fff', - fontSize: 17, - fontWeight: '600', - textAlign: 'right', - marginRight: 14, - lineHeight: 21, + fontSize: 22, + fontWeight: '700', + letterSpacing: -0.3, }, - sidePill: { - width: 6, - borderTopLeftRadius: 3, - borderBottomLeftRadius: 3, - backgroundColor: '#fff', + sheetBody: { + marginTop: 6, + color: '#9DB0AA', + fontSize: 15, + lineHeight: 21, }, status: { - position: 'absolute', - left: 0, - right: 0, + marginTop: 22, + flexDirection: 'row', alignItems: 'center', + gap: 12, + minHeight: GLYPH_SIZE, }, 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, + color: ACCENT, + fontSize: 17, + fontWeight: '600', }, }); diff --git a/example/src/screens/WeatherScreen.tsx b/example/src/screens/WeatherScreen.tsx index 5e46680..ece9e91 100644 --- a/example/src/screens/WeatherScreen.tsx +++ b/example/src/screens/WeatherScreen.tsx @@ -284,42 +284,6 @@ function getTempRange(daily: DailyItem[]) { return { overallLow: low, overallHigh: high, tempRange: high - low }; } -// Full temperature color stops (cold → hot) -const TEMP_COLORS = [ - { pos: 0, r: 90, g: 200, b: 250 }, // #5AC8FA — coldest - { pos: 0.33, r: 52, g: 199, b: 89 }, // #34C759 — cool - { pos: 0.66, r: 255, g: 204, b: 0 }, // #FFCC00 — warm - { pos: 1, r: 255, g: 149, b: 0 }, // #FF9500 — hot -]; - -function lerpColor(t: number): string { - const clamped = Math.max(0, Math.min(1, t)); - let i = 0; - while (i < TEMP_COLORS.length - 2 && TEMP_COLORS[i + 1]!.pos < clamped) { - i++; - } - const a = TEMP_COLORS[i]!; - const b = TEMP_COLORS[i + 1]!; - const local = (clamped - a.pos) / (b.pos - a.pos); - const r = Math.round(a.r + (b.r - a.r) * local); - const g = Math.round(a.g + (b.g - a.g) * local); - const bl = Math.round(a.b + (b.b - a.b) * local); - return `rgb(${r}, ${g}, ${bl})`; -} - -function barGradient( - low: number, - high: number, - overallLow: number, - tempRange: number -): string { - const tLow = (low - overallLow) / tempRange; - const tHigh = (high - overallLow) / tempRange; - return `linear-gradient(to right, ${lerpColor(tLow)}, ${lerpColor( - (tLow + tHigh) / 2 - )}, ${lerpColor(tHigh)})`; -} - export default function WeatherScreen() { const [scenario, setScenario] = useState('night'); const theme = WEATHER_THEMES[scenario]; @@ -560,7 +524,7 @@ export default function WeatherScreen() { {/* ═══ Fixed header — outside scroll view ═══ */} - Kraków + Sample forecast {forecast.currentTemp}° | {forecast.condition} @@ -590,7 +554,7 @@ export default function WeatherScreen() { {forecast.condition} - H:{forecast.high}° L:{forecast.low}° + High {forecast.high}° · Low {forecast.low}° @@ -611,9 +575,7 @@ export default function WeatherScreen() { > - - HOURLY FORECAST - + Hourly @@ -654,7 +616,7 @@ export default function WeatherScreen() { > - 10-DAY FORECAST + Next 10 days @@ -692,12 +654,7 @@ export default function WeatherScreen() { { left: `${barLeft}%`, width: `${Math.max(barWidth, 8)}%`, - experimental_backgroundImage: barGradient( - item.low, - item.high, - overallLow, - tempRange - ), + backgroundColor: colors.accentColor, }, ]} /> @@ -770,14 +727,11 @@ const styles = StyleSheet.create({ color: '#fff', }, cityName: { - fontSize: 36, - fontWeight: '400', + fontSize: 22, + fontWeight: '600', color: '#fff', - letterSpacing: 0.3, - textAlign: 'center', }, compactInfoWrap: { - alignItems: 'center', marginTop: 2, overflow: 'hidden', }, @@ -811,36 +765,35 @@ const styles = StyleSheet.create({ // --- Expanded header (collapses on scroll) --- expandedHeader: { - alignItems: 'center', + alignItems: 'flex-start', + paddingLeft: 4, paddingBottom: 28, overflow: 'hidden', }, tempWrap: { - alignItems: 'center', + alignItems: 'flex-start', }, temperature: { - fontSize: 96, - fontWeight: '100', + fontSize: 84, + fontWeight: '700', color: '#fff', - lineHeight: 104, + lineHeight: 96, }, degreeSymbol: { - fontSize: 96, - fontWeight: '100', + fontSize: 84, + fontWeight: '300', }, condition: { fontSize: 20, fontWeight: '500', color: 'rgba(255,255,255,0.85)', marginTop: 0, - textAlign: 'center', }, highLow: { fontSize: 20, fontWeight: '500', color: '#fff', marginTop: 2, - textAlign: 'center', }, // ═══ Cards ═══ diff --git a/example/src/types.ts b/example/src/types.ts index 7f059fa..d1e2441 100644 --- a/example/src/types.ts +++ b/example/src/types.ts @@ -4,22 +4,17 @@ export type RootStackParamList = { Home: undefined; Search: undefined; About: undefined; - CircularGradientList: undefined; - LinearGradientList: undefined; - LinearGradientAnimated: undefined; - LinearGradientStatic: undefined; IridescenceStatic: undefined; LiquidChromeStatic: undefined; SilkStatic: undefined; CampfireStatic: undefined; CalicoSwirlStatic: undefined; AuroraStatic: undefined; - SiriOrbStatic: undefined; + GlowOrbStatic: undefined; HoloCardExample: undefined; WeatherExample: undefined; - ScrollReactiveExample: undefined; TouchFieldExample: undefined; - ThanosExample: undefined; + DustExample: undefined; AiShimmerExample: undefined; AiOrbExample: undefined; AiVoiceExample: undefined; @@ -29,27 +24,26 @@ export type RootStackParamList = { MaterialsExample: undefined; BeerExample: undefined; LighterExample: undefined; - TelegramDissolveExample: undefined; + MessageDissolveExample: undefined; BirthdayCandleExample: undefined; ParticleDispersionExample: undefined; - FoamLabExample: undefined; VoiceDeleteExample: undefined; - Magic8BallExample: undefined; + MysticBallExample: undefined; DandelionExample: undefined; HourglassExample: undefined; - TelegramSpoilerExample: undefined; - IMessageExample: undefined; + HiddenTextExample: undefined; + SendEffectsExample: undefined; CrackedScreenExample: undefined; - DuoCrackgateExample: undefined; - PolaroidExample: undefined; + HingeCrackExample: undefined; + InstantCameraExample: undefined; ScratchOffExample: undefined; BubbleWrapExample: undefined; LavaLampExample: undefined; FerrofluidExample: undefined; CrtOffExample: undefined; FlashlightExample: undefined; - WalletPayExample: undefined; - NameDropExample: undefined; + MetalCardExample: undefined; + WaveRevealExample: undefined; }; export type HomeScreenNavigationProp =