From 1a2fa0ffb5cc1f5cfb6b571fc99581928ce58426 Mon Sep 17 00:00:00 2001 From: Mathis Pruvot Date: Thu, 28 May 2026 11:49:29 +0000 Subject: [PATCH] feat: add animation, gyroscope, and wallpaper export hooks useAnimation drives shader uniforms via Reanimated frame callbacks. useGyroscope accumulates device rotation with spring damping. useWallpaperExport bridges to native modules with platform detection and Expo Go fallback. --- src/hooks/useAnimation.ts | 50 +++++++++++ src/hooks/useGyroscope.ts | 43 ++++++++++ src/hooks/useWallpaperExport.ts | 147 ++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 src/hooks/useAnimation.ts create mode 100644 src/hooks/useGyroscope.ts create mode 100644 src/hooks/useWallpaperExport.ts diff --git a/src/hooks/useAnimation.ts b/src/hooks/useAnimation.ts new file mode 100644 index 0000000..22ffd94 --- /dev/null +++ b/src/hooks/useAnimation.ts @@ -0,0 +1,50 @@ +import { useCallback, useMemo } from "react"; +import { + useSharedValue, + useFrameCallback, + SharedValue, +} from "react-native-reanimated"; +import type { SkRuntimeEffect } from "@shopify/react-native-skia"; +import { shaders } from "@/shaders"; +import type { AnimationType, AnimationUniforms } from "@/types/wallpaper"; + +interface AnimationState { + time: SharedValue; + isPlaying: SharedValue; + play: () => void; + pause: () => void; + reset: () => void; + shader: SkRuntimeEffect | null; +} + +export function useAnimation( + animationType: AnimationType, + uniforms: AnimationUniforms +): AnimationState { + const time = useSharedValue(0); + const isPlaying = useSharedValue(true); + const startTime = useSharedValue(0); + + useFrameCallback((frameInfo) => { + if (isPlaying.value) { + time.value = (frameInfo.timeSinceFirstFrame ?? 0) / 1000; + } + }); + + const play = useCallback(() => { + isPlaying.value = true; + }, []); + + const pause = useCallback(() => { + isPlaying.value = false; + }, []); + + const reset = useCallback(() => { + time.value = 0; + startTime.value = 0; + }, []); + + const shader = useMemo(() => shaders[animationType], [animationType]); + + return { time, isPlaying, play, pause, reset, shader }; +} diff --git a/src/hooks/useGyroscope.ts b/src/hooks/useGyroscope.ts new file mode 100644 index 0000000..3a52edb --- /dev/null +++ b/src/hooks/useGyroscope.ts @@ -0,0 +1,43 @@ +import { useEffect } from "react"; +import { useSharedValue, withSpring } from "react-native-reanimated"; +import { Gyroscope } from "expo-sensors"; + +interface GyroscopeData { + x: ReturnType>; + y: ReturnType>; + available: boolean; +} + +export function useGyroscope(enabled: boolean = true): GyroscopeData { + const x = useSharedValue(0); + const y = useSharedValue(0); + + useEffect(() => { + if (!enabled) return; + + let available = true; + Gyroscope.isAvailableAsync().then((result) => { + available = result; + }); + + Gyroscope.setUpdateInterval(50); // 20Hz — balanced power/responsiveness + + const subscription = Gyroscope.addListener((data) => { + // Accumulate rotation with damping + x.value = withSpring(x.value + data.x * 0.05, { + damping: 20, + stiffness: 100, + }); + y.value = withSpring(y.value + data.y * 0.05, { + damping: 20, + stiffness: 100, + }); + }); + + return () => { + subscription.remove(); + }; + }, [enabled]); + + return { x, y, available: true }; +} diff --git a/src/hooks/useWallpaperExport.ts b/src/hooks/useWallpaperExport.ts new file mode 100644 index 0000000..f480110 --- /dev/null +++ b/src/hooks/useWallpaperExport.ts @@ -0,0 +1,147 @@ +import { useState, useCallback } from "react"; +import { Platform, Alert } from "react-native"; +import type { WallpaperConfig } from "@/types/wallpaper"; + +type ExportStatus = "idle" | "preparing" | "exporting" | "success" | "error"; + +interface ExportResult { + status: ExportStatus; + progress: number; + error: string | null; + exportAsWallpaper: (config: WallpaperConfig) => Promise; +} + +export function useWallpaperExport(): ExportResult { + const [status, setStatus] = useState("idle"); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + + const exportAsWallpaper = useCallback(async (config: WallpaperConfig) => { + try { + setStatus("preparing"); + setProgress(0); + setError(null); + + if (Platform.OS === "android") { + await exportAndroid(config, setProgress, setStatus); + } else if (Platform.OS === "ios") { + await exportIOS(config, setProgress, setStatus); + } else { + throw new Error("Platform non supportée. Utilisez Android ou iOS."); + } + + setStatus("success"); + setProgress(1); + } catch (err) { + const message = err instanceof Error ? err.message : "Échec de l'export"; + setError(message); + setStatus("error"); + Alert.alert("Erreur d'export", message); + } + }, []); + + return { status, progress, error, exportAsWallpaper }; +} + +async function exportAndroid( + config: WallpaperConfig, + onProgress: (p: number) => void, + onStatus: (s: ExportStatus) => void +) { + // Lazy-load native module (only available on Android device builds) + let WallpaperAndroid: typeof import("../../modules/wallpaper-android/src/WallpaperAndroidModule").WallpaperAndroid; + try { + WallpaperAndroid = + require("../../modules/wallpaper-android/src/WallpaperAndroidModule").WallpaperAndroid; + } catch { + Alert.alert( + "Module non disponible", + "Le module natif Android n'est pas disponible dans Expo Go. Créez un development build avec `npx expo run:android`.", + ); + return; + } + + // Check support + const supported = WallpaperAndroid.isLiveWallpaperSupported(); + if (!supported) { + throw new Error("Les live wallpapers ne sont pas supportés sur cet appareil."); + } + + onProgress(0.2); + onStatus("exporting"); + + // Save config to SharedPreferences (read by LiveWallpaperService) + const configJson = JSON.stringify({ + sourceUri: config.sourceUri, + depthMapUri: config.depthMapUri ?? "", + animation: config.animation, + uniforms: config.uniforms, + fps: config.fps, + }); + + await WallpaperAndroid.saveConfig(configJson); + onProgress(0.6); + + // Launch system wallpaper picker + await WallpaperAndroid.setLiveWallpaper(); + onProgress(1.0); +} + +async function exportIOS( + config: WallpaperConfig, + onProgress: (p: number) => void, + onStatus: (s: ExportStatus) => void +) { + let LivePhoto: typeof import("../../modules/livephoto-ios/src/LivePhotoModule").LivePhoto; + try { + LivePhoto = + require("../../modules/livephoto-ios/src/LivePhotoModule").LivePhoto; + } catch { + Alert.alert( + "Module non disponible", + "Le module natif iOS n'est pas disponible dans Expo Go. Créez un development build avec `npx expo run:ios`.", + ); + return; + } + + // Check/request photo library permission + let permission = await LivePhoto.checkPermission(); + if (permission === "undetermined") { + permission = await LivePhoto.requestPermission(); + } + if (permission !== "granted") { + throw new Error( + "Accès à la photothèque refusé. Activez-le dans Réglages > Lively > Photos." + ); + } + + onProgress(0.1); + onStatus("exporting"); + + const configJson = JSON.stringify({ + sourceUri: config.sourceUri, + depthMapUri: config.depthMapUri ?? "", + animation: config.animation, + uniforms: config.uniforms, + fps: config.fps, + }); + + onProgress(0.3); + + // Generate Live Photo (HEIC + MOV) and save to photo library + await LivePhoto.exportLivePhoto(configJson); + + onProgress(0.9); + + // Guide user to apply the wallpaper + Alert.alert( + "Live Photo sauvegardée", + "Pour l'appliquer comme fond d'écran :\n\n" + + "Réglages > Fond d'écran > Ajouter\n" + + "> Photos > Sélectionnez votre Live Photo\n" + + "> Activez « Live Photo »", + [{ text: "OK" }] + ); + + onProgress(1.0); +}