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.
This commit is contained in:
Mathis Pruvot
2026-05-28 11:49:29 +00:00
parent da5602f3b4
commit 1a2fa0ffb5
3 changed files with 240 additions and 0 deletions

50
src/hooks/useAnimation.ts Normal file
View File

@@ -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<number>;
isPlaying: SharedValue<boolean>;
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 };
}

43
src/hooks/useGyroscope.ts Normal file
View File

@@ -0,0 +1,43 @@
import { useEffect } from "react";
import { useSharedValue, withSpring } from "react-native-reanimated";
import { Gyroscope } from "expo-sensors";
interface GyroscopeData {
x: ReturnType<typeof useSharedValue<number>>;
y: ReturnType<typeof useSharedValue<number>>;
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 };
}

View File

@@ -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<void>;
}
export function useWallpaperExport(): ExportResult {
const [status, setStatus] = useState<ExportStatus>("idle");
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(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);
}