Compare commits

...

10 Commits

Author SHA1 Message Date
Mathis Pruvot
deb672d139 chore: add .claude/ to gitignore 2026-05-28 11:50:48 +00:00
Mathis Pruvot
187d3391d0 feat: add iOS Live Photo export native module
AVAssetWriter pipeline generating 3s MOV at 30fps with
Apple content identifier metadata and still-image-time.
HEIC generation with maker note pairing. PHAssetCreation
with photo + pairedVideo resources. CIFilter-based effects
for Ken Burns, Color Shift, Vignette, and Glitch.
2026-05-28 11:50:28 +00:00
Mathis Pruvot
9b90e6a4a1 fix: include native module sources in git tracking
Fix .gitignore to only exclude root android/ and ios/
(Expo prebuild output) while keeping modules/ native code.
Add Android WallpaperService Kotlin sources, GLSL shaders,
manifest, and Gradle config.
2026-05-28 11:50:19 +00:00
Mathis Pruvot
84809378d1 feat: add Android live wallpaper native module
WallpaperService with OpenGL ES 2.0 rendering, 6 GLSL
fragment shaders, gyroscope integration, frame capping at
24fps with visibility-based pause. Expo Module API bridge
for config persistence and system wallpaper picker launch.
2026-05-28 11:49:57 +00:00
Mathis Pruvot
0e1716738e feat: add wallpaper editor with live shader preview
Full-screen Skia canvas with real-time shader preview,
glass bottom sheet controls (animation selector, intensity/
speed/direction sliders, particle preset picker), save and
export actions with status feedback.
2026-05-28 11:49:52 +00:00
Mathis Pruvot
c3073b3f70 feat: add photo picker with gallery grid
Photo grid from device gallery via expo-media-library Query
API, permission handling with settings redirect, loading
states, and corrupt asset resilience.
2026-05-28 11:49:48 +00:00
Mathis Pruvot
fbf8094281 feat: add navigation with tab bar and screen layouts
Expo Router file-based navigation with glass tab bar,
3 tabs (Créations, Explorer, Profil), Phosphor icons,
modal picker, and fade transitions.
2026-05-28 11:49:39 +00:00
Mathis Pruvot
5cddb440e6 feat: add reusable AnimationCanvas component
Standalone Skia canvas for wallpaper thumbnail previews
with configurable FPS cap for battery efficiency.
2026-05-28 11:49:34 +00:00
Mathis Pruvot
1a2fa0ffb5 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.
2026-05-28 11:49:29 +00:00
Mathis Pruvot
da5602f3b4 feat: add gallery service and ML depth estimation placeholder
Media library access via new expo-media-library Query API,
image resize for wallpaper export, and MiDaS depth map
service stub for V1.5.
2026-05-28 11:49:23 +00:00
27 changed files with 2858 additions and 2 deletions

5
.gitignore vendored
View File

@@ -1,7 +1,7 @@
node_modules/ node_modules/
.expo/ .expo/
android/ /android/
ios/ /ios/
dist/ dist/
.env .env
*.jks *.jks
@@ -11,3 +11,4 @@ dist/
*.mobileprovision *.mobileprovision
*.orig.* *.orig.*
web-build/ web-build/
.claude/

44
app/(tabs)/_layout.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { Tabs } from "expo-router";
import { GlassTabBar } from "@/components/glass";
import { House, Compass, UserCircle } from "phosphor-react-native";
import type { ColorValue } from "react-native";
export default function TabLayout() {
return (
<Tabs
tabBar={(props) => <GlassTabBar {...props} />}
screenOptions={{
headerShown: false,
tabBarStyle: { display: "none" },
}}
>
<Tabs.Screen
name="index"
options={{
title: "Créations",
tabBarIcon: ({ color, size }: { color: ColorValue; size: number }) => (
<House size={size} color={color as string} weight="light" />
),
}}
/>
<Tabs.Screen
name="explore"
options={{
title: "Explorer",
tabBarIcon: ({ color, size }: { color: ColorValue; size: number }) => (
<Compass size={size} color={color as string} weight="light" />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profil",
tabBarIcon: ({ color, size }: { color: ColorValue; size: number }) => (
<UserCircle size={size} color={color as string} weight="light" />
),
}}
/>
</Tabs>
);
}

60
app/(tabs)/explore.tsx Normal file
View File

@@ -0,0 +1,60 @@
import { StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { FadeInDown } from "react-native-reanimated";
import { GlassCard } from "@/components/glass";
import { colors, typography, spacing, layout } from "@/theme";
export default function ExploreScreen() {
const insets = useSafeAreaInsets();
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.md }]}>
<Animated.View entering={FadeInDown.duration(500)}>
<GlassCard variant="subtle" radius="lg" padding={16}>
<Text style={typography.title}>Explorer</Text>
<Text style={[typography.caption, { marginTop: 2 }]}>
Découvrez la communauté
</Text>
</GlassCard>
</Animated.View>
<Animated.View
entering={FadeInDown.delay(200).duration(600)}
style={styles.placeholder}
>
<GlassCard variant="medium" radius="xl" padding={32}>
<View style={styles.content}>
<Text style={[typography.heading, { textAlign: "center" }]}>
Bientôt disponible
</Text>
<Text
style={[
typography.bodySecondary,
{ textAlign: "center", marginTop: 8 },
]}
>
Partagez vos créations et découvrez{"\n"}
celles de la communauté V1.5
</Text>
</View>
</GlassCard>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
paddingHorizontal: layout.screenPadding,
},
placeholder: {
flex: 1,
justifyContent: "center",
paddingHorizontal: spacing.md,
},
content: {
alignItems: "center",
},
});

232
app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,232 @@
import { useCallback } from "react";
import {
Dimensions,
FlatList,
Image,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import Animated, { FadeInDown } from "react-native-reanimated";
import { Plus, ImageSquare } from "phosphor-react-native";
import { GlassCard, GlassButton } from "@/components/glass";
import { useWallpaperStore } from "@/stores/wallpaper.store";
import { colors, typography, spacing, layout } from "@/theme";
import { ANIMATION_META, type WallpaperConfig } from "@/types/wallpaper";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const CARD_SIZE = (SCREEN_WIDTH - layout.screenPadding * 2 - spacing.md) / 2;
export default function HomeScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const savedWallpapers = useWallpaperStore((s) => s.savedWallpapers);
const handleNewWallpaper = useCallback(() => {
router.push("/picker");
}, [router]);
const handleOpenWallpaper = useCallback(
(config: WallpaperConfig) => {
const store = useWallpaperStore.getState();
store.setSourceImage(config.sourceUri);
store.setAnimation(config.animation);
Object.entries(config.uniforms).forEach(([key, value]) => {
store.setUniform(key, value);
});
router.push(`/editor/${config.id}`);
},
[router]
);
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.md }]}>
{/* Header */}
<Animated.View
entering={FadeInDown.duration(500)}
style={styles.header}
>
<GlassCard variant="subtle" radius="lg" padding={16}>
<View style={styles.headerRow}>
<View>
<Text style={typography.title}>Lively</Text>
<Text style={[typography.caption, { marginTop: 2 }]}>
Vos fonds d'écran animés
</Text>
</View>
</View>
</GlassCard>
</Animated.View>
{/* Content */}
{savedWallpapers.length === 0 ? (
<EmptyState onPress={handleNewWallpaper} />
) : (
<FlatList
data={savedWallpapers}
numColumns={2}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.grid}
columnWrapperStyle={styles.row}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View style={styles.sectionHeader}>
<Text style={typography.label}>Récentes</Text>
<Text style={[typography.caption, { color: colors.text.secondary }]}>
{savedWallpapers.length} création{savedWallpapers.length > 1 ? "s" : ""}
</Text>
</View>
}
renderItem={({ item, index }) => (
<WallpaperCard
config={item}
index={index}
onPress={() => handleOpenWallpaper(item)}
/>
)}
/>
)}
{/* Floating CTA */}
{savedWallpapers.length > 0 && (
<Animated.View
entering={FadeInDown.delay(300).duration(400)}
style={[styles.fabContainer, { bottom: layout.tabBarHeight + insets.bottom + 16 }]}
>
<GlassButton
label="Nouveau"
onPress={handleNewWallpaper}
variant="primary"
size="lg"
pulse
/>
</Animated.View>
)}
</View>
);
}
function EmptyState({ onPress }: { onPress: () => void }) {
return (
<Animated.View
entering={FadeInDown.delay(200).duration(600)}
style={styles.emptyContainer}
>
<GlassCard variant="medium" radius="xl" padding={32}>
<View style={styles.emptyContent}>
<Text style={[typography.heading, { textAlign: "center" }]}>
Donnez vie à vos photos
</Text>
<Text
style={[
typography.bodySecondary,
{ textAlign: "center", marginTop: 8, marginBottom: 24 },
]}
>
Choisissez une image et appliquez{"\n"}une animation en quelques taps
</Text>
<GlassButton
label="Choisir une photo"
onPress={onPress}
variant="primary"
size="lg"
pulse
/>
</View>
</GlassCard>
</Animated.View>
);
}
function WallpaperCard({
config,
index,
onPress,
}: {
config: WallpaperConfig;
index: number;
onPress: () => void;
}) {
const meta = ANIMATION_META[config.animation];
return (
<Animated.View entering={FadeInDown.delay(index * 80).duration(400)}>
<Pressable onPress={onPress}>
<GlassCard variant="subtle" radius="lg" noPadding>
<Image
source={{ uri: config.sourceUri }}
style={styles.cardImage}
resizeMode="cover"
/>
<View style={styles.cardOverlay}>
<Text style={styles.cardLabel}>{meta.label}</Text>
</View>
</GlassCard>
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
paddingHorizontal: layout.screenPadding,
},
header: {
marginBottom: spacing.lg,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
sectionHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.md,
},
grid: {
paddingBottom: 160,
},
row: {
gap: spacing.md,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
paddingHorizontal: spacing.md,
},
emptyContent: {
alignItems: "center",
},
cardImage: {
width: CARD_SIZE,
height: CARD_SIZE * 1.4,
borderRadius: 24,
},
cardOverlay: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: 12,
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
backgroundColor: "rgba(0, 0, 0, 0.4)",
},
cardLabel: {
...typography.caption,
color: colors.text.primary,
fontWeight: "600",
},
fabContainer: {
position: "absolute",
left: layout.screenPadding,
right: layout.screenPadding,
},
});

242
app/(tabs)/profile.tsx Normal file
View File

@@ -0,0 +1,242 @@
import { ScrollView, StyleSheet, Switch, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { FadeInDown } from "react-native-reanimated";
import { GlassCard } from "@/components/glass";
import { useSettingsStore } from "@/stores/settings.store";
import { useWallpaperStore } from "@/stores/wallpaper.store";
import { colors, typography, spacing, layout } from "@/theme";
export default function ProfileScreen() {
const insets = useSafeAreaInsets();
const settings = useSettingsStore();
const wallpaperCount = useWallpaperStore((s) => s.savedWallpapers.length);
return (
<ScrollView
style={[styles.container, { paddingTop: insets.top + spacing.md }]}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<Animated.View entering={FadeInDown.duration(500)}>
<GlassCard variant="subtle" radius="lg" padding={16}>
<Text style={typography.title}>Profil</Text>
<Text style={[typography.caption, { marginTop: 2 }]}>
Préférences & réglages
</Text>
</GlassCard>
</Animated.View>
{/* Stats */}
<Animated.View entering={FadeInDown.delay(100).duration(400)} style={styles.section}>
<GlassCard variant="medium" radius="lg">
<Text style={typography.label}>Statistiques</Text>
<View style={styles.statsRow}>
<View style={styles.stat}>
<Text style={typography.title}>{wallpaperCount}</Text>
<Text style={typography.caption}>Créations</Text>
</View>
<View style={styles.statDivider} />
<View style={styles.stat}>
<Text style={typography.title}>5</Text>
<Text style={typography.caption}>Animations</Text>
</View>
</View>
</GlassCard>
</Animated.View>
{/* Settings */}
<Animated.View entering={FadeInDown.delay(200).duration(400)} style={styles.section}>
<GlassCard variant="medium" radius="lg">
<Text style={[typography.label, { marginBottom: 16 }]}>Réglages</Text>
<SettingRow
label="Retour haptique"
description="Vibrations au toucher"
value={settings.hapticFeedback}
onToggle={settings.setHapticFeedback}
/>
<SettingRow
label="Depth map auto"
description="Générer automatiquement (V1.5)"
value={settings.autoDepthMap}
onToggle={settings.setAutoDepthMap}
/>
</GlassCard>
</Animated.View>
{/* Export Quality */}
<Animated.View entering={FadeInDown.delay(300).duration(400)} style={styles.section}>
<GlassCard variant="medium" radius="lg">
<Text style={[typography.label, { marginBottom: 16 }]}>
Qualité d'export
</Text>
{(["low", "medium", "high"] as const).map((q) => (
<QualityOption
key={q}
label={q === "low" ? "Économique" : q === "medium" ? "Standard" : "Maximum"}
description={
q === "low"
? "15 FPS, compression forte"
: q === "medium"
? "24 FPS, bon compromis"
: "30 FPS, qualité maximale"
}
selected={settings.exportQuality === q}
onPress={() => settings.setExportQuality(q)}
/>
))}
</GlassCard>
</Animated.View>
{/* About */}
<Animated.View entering={FadeInDown.delay(400).duration(400)} style={styles.section}>
<GlassCard variant="subtle" radius="lg">
<Text style={typography.label}>À propos</Text>
<Text style={[typography.bodySecondary, { marginTop: 8 }]}>
Lively v1.0.0
</Text>
<Text style={[typography.caption, { marginTop: 4 }]}>
Fonds d'écran animés depuis votre galerie
</Text>
</GlassCard>
</Animated.View>
<View style={{ height: layout.tabBarHeight + 40 }} />
</ScrollView>
);
}
function SettingRow({
label,
description,
value,
onToggle,
}: {
label: string;
description: string;
value: boolean;
onToggle: (v: boolean) => void;
}) {
return (
<View style={styles.settingRow}>
<View style={styles.settingText}>
<Text style={typography.body}>{label}</Text>
<Text style={typography.caption}>{description}</Text>
</View>
<Switch
value={value}
onValueChange={onToggle}
trackColor={{
false: "rgba(255,255,255,0.1)",
true: "rgba(255,255,255,0.3)",
}}
thumbColor={value ? colors.text.primary : colors.text.tertiary}
/>
</View>
);
}
function QualityOption({
label,
description,
selected,
onPress,
}: {
label: string;
description: string;
selected: boolean;
onPress: () => void;
}) {
return (
<View
style={[styles.qualityRow, selected && styles.qualitySelected]}
onTouchEnd={onPress}
>
<View style={styles.settingText}>
<Text style={[typography.body, selected && { color: colors.text.primary }]}>
{label}
</Text>
<Text style={typography.caption}>{description}</Text>
</View>
<View
style={[
styles.radio,
selected && styles.radioSelected,
]}
>
{selected && <View style={styles.radioDot} />}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
paddingHorizontal: layout.screenPadding,
},
content: {
paddingBottom: 40,
},
section: {
marginTop: spacing.md,
},
statsRow: {
flexDirection: "row",
marginTop: 16,
alignItems: "center",
},
stat: {
flex: 1,
alignItems: "center",
},
statDivider: {
width: 1,
height: 40,
backgroundColor: colors.glass.border,
},
settingRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.glass.highlight,
},
settingText: {
flex: 1,
marginRight: 16,
},
qualityRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 12,
borderRadius: 12,
marginBottom: 4,
},
qualitySelected: {
backgroundColor: "rgba(255, 255, 255, 0.06)",
},
radio: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 1.5,
borderColor: colors.text.tertiary,
alignItems: "center",
justifyContent: "center",
},
radioSelected: {
borderColor: colors.text.primary,
},
radioDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.text.primary,
},
});

48
app/_layout.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { useEffect } from "react";
import { StatusBar } from "expo-status-bar";
import { Stack } from "expo-router";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native";
import { useWallpaperStore } from "@/stores/wallpaper.store";
import { useSettingsStore } from "@/stores/settings.store";
import { colors } from "@/theme";
export default function RootLayout() {
const loadWallpapers = useWallpaperStore((s) => s.loadSavedWallpapers);
const loadSettings = useSettingsStore((s) => s.loadSettings);
useEffect(() => {
loadWallpapers();
loadSettings();
}, []);
return (
<GestureHandlerRootView style={styles.root}>
<StatusBar style="light" />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
animation: "fade",
}}
>
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="picker"
options={{ animation: "slide_from_bottom", presentation: "modal" }}
/>
<Stack.Screen
name="editor/[id]"
options={{ animation: "fade" }}
/>
</Stack>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: colors.background,
},
});

308
app/editor/[id].tsx Normal file
View File

@@ -0,0 +1,308 @@
import { useCallback, useMemo } from "react";
import {
ActivityIndicator,
Dimensions,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useRouter, useLocalSearchParams } from "expo-router";
import {
Canvas,
Image as SkImage,
Shader,
Fill,
useImage,
} from "@shopify/react-native-skia";
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
import { ArrowLeft, Check, Export } from "phosphor-react-native";
import { GlassBottomSheet, GlassButton, GlassCard, GlassPill, GlassSlider } from "@/components/glass";
import { useWallpaperStore } from "@/stores/wallpaper.store";
import { useAnimation } from "@/hooks/useAnimation";
import { useGyroscope } from "@/hooks/useGyroscope";
import { useWallpaperExport } from "@/hooks/useWallpaperExport";
import { shaders } from "@/shaders";
import {
ANIMATION_META,
type AnimationType,
} from "@/types/wallpaper";
import { colors, typography, spacing, layout } from "@/theme";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
const AVAILABLE_ANIMATIONS: AnimationType[] = [
"ken-burns",
"color-shift",
"particles",
"vignette-pulse",
"glitch",
];
export default function EditorScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const {
sourceUri,
animation,
uniforms,
particlePreset,
setAnimation,
setUniform,
setParticlePreset,
saveWallpaper,
} = useWallpaperStore();
const { time } = useAnimation(animation, uniforms);
const gyro = useGyroscope(animation === "depth-parallax");
const { status, exportAsWallpaper } = useWallpaperExport();
const image = useImage(sourceUri);
const shader = useMemo(() => shaders[animation], [animation]);
const handleApply = useCallback(async () => {
const config = saveWallpaper();
if (config) {
await exportAsWallpaper(config);
}
}, [saveWallpaper, exportAsWallpaper]);
const handleSaveOnly = useCallback(() => {
saveWallpaper();
router.back();
}, [saveWallpaper, router]);
// No image selected — redirect back
if (!sourceUri) {
return (
<View style={[styles.container, styles.centered]}>
<GlassCard variant="medium" radius="xl" padding={32}>
<Text style={[typography.heading, { textAlign: "center" }]}>
Aucune image sélectionnée
</Text>
<Text
style={[typography.bodySecondary, { textAlign: "center", marginTop: 8, marginBottom: 20 }]}
>
Retournez à la galerie pour choisir une photo
</Text>
<GlassButton
label="Retour"
onPress={() => router.back()}
variant="primary"
size="md"
/>
</GlassCard>
</View>
);
}
// Image loading
if (!image) {
return (
<View style={[styles.container, styles.centered]}>
<ActivityIndicator color={colors.text.secondary} size="large" />
<Text style={[typography.bodySecondary, { marginTop: 16 }]}>
Chargement de l'image...
</Text>
</View>
);
}
return (
<View style={styles.container}>
{/* Full-screen animated preview */}
<Canvas style={styles.canvas}>
{shader && (
<Fill>
<Shader
source={shader}
uniforms={{
resolution: [SCREEN_WIDTH, SCREEN_HEIGHT],
time: time.value,
intensity: uniforms.intensity,
speed: uniforms.speed,
direction: uniforms.direction,
particleType: particlePreset === "snow" ? 0 : particlePreset === "rain" ? 1 : 2,
gyroX: gyro.x.value,
gyroY: gyro.y.value,
}}
>
<SkImage
image={image}
x={0}
y={0}
width={SCREEN_WIDTH}
height={SCREEN_HEIGHT}
fit="cover"
/>
</Shader>
</Fill>
)}
</Canvas>
{/* Top bar overlay */}
<Animated.View
entering={FadeIn.delay(200).duration(300)}
style={[styles.topBar, { top: insets.top + 8 }]}
>
<Pressable onPress={() => router.back()} style={styles.iconButton}>
<ArrowLeft size={20} color={colors.text.primary} weight="light" />
</Pressable>
<Pressable onPress={handleSaveOnly} style={styles.iconButton}>
<Check size={20} color={colors.text.primary} weight="light" />
</Pressable>
</Animated.View>
{/* Bottom sheet with controls */}
<GlassBottomSheet initialState="peek">
<ScrollView showsVerticalScrollIndicator={false}>
{/* Animation selector */}
<Text style={[typography.label, { marginBottom: 12 }]}>Animation</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.animationScroll}
>
{AVAILABLE_ANIMATIONS.map((type) => (
<GlassPill
key={type}
label={ANIMATION_META[type].label}
selected={animation === type}
onPress={() => setAnimation(type)}
/>
))}
</ScrollView>
{/* Particle preset selector (only for particles animation) */}
{animation === "particles" && (
<>
<Text style={[typography.label, { marginBottom: 12 }]}>Style</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.animationScroll}
>
{(["snow", "rain", "bokeh"] as const).map((preset) => (
<GlassPill
key={preset}
label={preset === "snow" ? "Neige" : preset === "rain" ? "Pluie" : "Bokeh"}
selected={particlePreset === preset}
onPress={() => setParticlePreset(preset)}
/>
))}
</ScrollView>
</>
)}
{/* Uniform controls */}
<View style={styles.controls}>
<GlassSlider
label="Intensité"
value={uniforms.intensity}
min={0}
max={1}
onValueChange={(v) => setUniform("intensity", v)}
/>
<GlassSlider
label="Vitesse"
value={uniforms.speed}
min={0.05}
max={1}
onValueChange={(v) => setUniform("speed", v)}
/>
{(animation === "ken-burns" || animation === "glitch") && (
<GlassSlider
label="Direction"
value={uniforms.direction}
min={0}
max={6.28}
step={0.1}
onValueChange={(v) => setUniform("direction", v)}
formatValue={(v) => `${Math.round((v * 180) / Math.PI)}°`}
/>
)}
</View>
{/* Export button */}
<View style={styles.ctaContainer}>
<GlassButton
label={
status === "preparing"
? "Préparation..."
: status === "exporting"
? "Export en cours..."
: status === "success"
? "Exporté !"
: "Appliquer comme fond d'écran"
}
onPress={handleApply}
variant="primary"
size="lg"
loading={status === "preparing" || status === "exporting"}
disabled={status === "success"}
pulse={status === "idle"}
icon={
status === "idle" ? (
<Export size={18} color={colors.text.primary} weight="light" />
) : undefined
}
/>
</View>
<View style={{ height: 40 }} />
</ScrollView>
</GlassBottomSheet>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
centered: {
justifyContent: "center",
alignItems: "center",
paddingHorizontal: layout.screenPadding,
},
canvas: {
position: "absolute",
top: 0,
left: 0,
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
},
topBar: {
position: "absolute",
left: 16,
right: 16,
flexDirection: "row",
justifyContent: "space-between",
zIndex: 10,
},
iconButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(0, 0, 0, 0.4)",
borderWidth: 1,
borderColor: colors.glass.border,
alignItems: "center",
justifyContent: "center",
},
animationScroll: {
marginBottom: spacing.lg,
},
controls: {
marginBottom: spacing.lg,
},
ctaContainer: {
marginTop: spacing.sm,
},
});

266
app/picker.tsx Normal file
View File

@@ -0,0 +1,266 @@
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Dimensions,
FlatList,
Image,
Linking,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import * as MediaLibrary from "expo-media-library";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { X } from "phosphor-react-native";
import { GlassCard, GlassButton, GlassPill } from "@/components/glass";
import { useWallpaperStore } from "@/stores/wallpaper.store";
import { requestGalleryPermission } from "@/services/media/gallery.service";
import { colors, typography, spacing, layout } from "@/theme";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const NUM_COLUMNS = 3;
const IMAGE_GAP = 2;
const IMAGE_SIZE = (SCREEN_WIDTH - IMAGE_GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS;
type Tab = "recent" | "favorites" | "albums";
interface PhotoItem {
id: string;
uri: string;
}
export default function PickerScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const setSourceImage = useWallpaperStore((s) => s.setSourceImage);
const resetEditor = useWallpaperStore((s) => s.resetEditor);
const [tab, setTab] = useState<Tab>("recent");
const [photos, setPhotos] = useState<PhotoItem[]>([]);
const [hasPermission, setHasPermission] = useState(false);
const [loading, setLoading] = useState(true);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
useEffect(() => {
(async () => {
const granted = await requestGalleryPermission();
setHasPermission(granted);
if (granted) {
await loadPhotos(0);
}
setLoading(false);
})();
}, []);
const loadPhotos = useCallback(async (startOffset: number) => {
try {
const query = new MediaLibrary.Query()
.eq(MediaLibrary.AssetField.MEDIA_TYPE, MediaLibrary.MediaType.IMAGE)
.orderBy({
key: MediaLibrary.AssetField.CREATION_TIME,
ascending: false,
})
.limit(60)
.offset(startOffset);
const assets = await query.exe();
// Resolve URIs from assets
const items: PhotoItem[] = [];
for (const asset of assets) {
try {
const uri = await asset.getUri();
items.push({ id: asset.id, uri });
} catch {
// Skip assets that can't be resolved (corrupted, cloud-only, etc.)
}
}
setPhotos((prev) => (startOffset === 0 ? items : [...prev, ...items]));
setOffset(startOffset + assets.length);
setHasMore(assets.length === 60);
} catch {
// Gallery read error — device-specific edge case
}
}, []);
const loadMore = useCallback(() => {
if (hasMore) {
loadPhotos(offset);
}
}, [hasMore, offset, loadPhotos]);
const handleSelectPhoto = useCallback(
(item: PhotoItem) => {
resetEditor();
setSourceImage(item.uri);
router.replace("/editor/new");
},
[router, setSourceImage, resetEditor]
);
if (loading) {
return (
<View style={[styles.container, styles.centered, { paddingTop: insets.top }]}>
<ActivityIndicator color={colors.text.secondary} size="large" />
</View>
);
}
if (!hasPermission) {
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<PermissionDenied />
</View>
);
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<Animated.View entering={FadeIn.duration(300)} style={styles.header}>
<GlassCard variant="subtle" radius="lg" padding={12}>
<View style={styles.headerRow}>
<Pressable onPress={() => router.back()}>
<X size={20} color={colors.text.primary} weight="light" />
</Pressable>
<Text style={typography.heading}>Choisir une photo</Text>
<View style={{ width: 24 }} />
</View>
</GlassCard>
</Animated.View>
{/* Tab pills */}
<Animated.View entering={FadeInDown.delay(100).duration(300)} style={styles.tabs}>
<GlassPill
label="Récentes"
selected={tab === "recent"}
onPress={() => setTab("recent")}
/>
<GlassPill
label="Favoris"
selected={tab === "favorites"}
onPress={() => setTab("favorites")}
/>
<GlassPill
label="Albums"
selected={tab === "albums"}
onPress={() => setTab("albums")}
/>
</Animated.View>
{/* Photo grid */}
<FlatList
data={photos}
numColumns={NUM_COLUMNS}
keyExtractor={(item) => item.id}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.grid, photos.length === 0 && styles.centered]}
ListEmptyComponent={
<View style={styles.emptyGallery}>
<Text style={[typography.bodySecondary, { textAlign: "center" }]}>
Aucune photo trouvée dans votre galerie
</Text>
</View>
}
renderItem={({ item }) => (
<Pressable
onPress={() => handleSelectPhoto(item)}
style={({ pressed }) => [
styles.imageContainer,
pressed && styles.imagePressed,
]}
>
<Image
source={{ uri: item.uri }}
style={styles.image}
resizeMode="cover"
/>
</Pressable>
)}
/>
</View>
);
}
function PermissionDenied() {
return (
<View style={styles.permissionContainer}>
<GlassCard variant="medium" radius="xl" padding={32}>
<Text style={[typography.heading, { textAlign: "center" }]}>
Accès aux photos requis
</Text>
<Text
style={[
typography.bodySecondary,
{ textAlign: "center", marginTop: 8, marginBottom: 20 },
]}
>
Lively a besoin d'accéder à votre galerie{"\n"}pour créer des fonds
d'écran animés
</Text>
<GlassButton
label="Ouvrir les réglages"
onPress={() => Linking.openSettings()}
variant="primary"
size="md"
/>
</GlassCard>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
paddingHorizontal: layout.screenPadding,
marginBottom: spacing.sm,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
tabs: {
flexDirection: "row",
paddingHorizontal: layout.screenPadding,
marginBottom: spacing.md,
},
grid: {
paddingBottom: 40,
},
imageContainer: {
width: IMAGE_SIZE,
height: IMAGE_SIZE,
padding: IMAGE_GAP / 2,
},
image: {
flex: 1,
},
imagePressed: {
opacity: 0.7,
},
permissionContainer: {
flex: 1,
justifyContent: "center",
paddingHorizontal: layout.screenPadding + spacing.md,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyGallery: {
paddingTop: 80,
paddingHorizontal: layout.screenPadding,
},
});

View File

@@ -0,0 +1,6 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["LivePhotoModule"]
}
}

View File

@@ -0,0 +1,379 @@
import AVFoundation
import CoreImage
import Photos
import UIKit
/// Generates a Live Photo (HEIC image + MOV video) from a static image with animation applied.
///
/// Pipeline:
/// 1. Render 90 frames (3 seconds at 30fps) with the selected shader effect
/// 2. Encode frames into a MOV file with paired metadata
/// 3. Save the source image as HEIC with the same content identifier
/// 4. Create a PHAsset with both resources (photo + paired video)
class LivePhotoExporter {
private let fps: Int32 = 30
private let duration: Double = 3.0
func export(
image: UIImage,
targetSize: CGSize,
shaderId: String,
intensity: Float,
speed: Float,
direction: Float
) async throws -> String {
let assetIdentifier = UUID().uuidString
let tempDir = FileManager.default.temporaryDirectory
let movURL = tempDir.appendingPathComponent("lively_\(assetIdentifier).mov")
let heicURL = tempDir.appendingPathComponent("lively_\(assetIdentifier).heic")
// Clean up any existing files
try? FileManager.default.removeItem(at: movURL)
try? FileManager.default.removeItem(at: heicURL)
// Resize source image to target size
let resizedImage = resizeImage(image, to: targetSize)
guard let cgImage = resizedImage.cgImage else {
throw ExportError.imageConversionFailed
}
// Step 1: Generate MOV with animated frames
try await generateMOV(
sourceImage: cgImage,
targetSize: targetSize,
outputURL: movURL,
assetIdentifier: assetIdentifier,
shaderId: shaderId,
intensity: intensity,
speed: speed,
direction: direction
)
// Step 2: Generate HEIC with paired metadata
try generateHEIC(
sourceImage: cgImage,
outputURL: heicURL,
assetIdentifier: assetIdentifier
)
// Step 3: Save to photo library as Live Photo
let localIdentifier = try await saveLivePhoto(
imageURL: heicURL,
videoURL: movURL
)
// Cleanup temp files
try? FileManager.default.removeItem(at: movURL)
try? FileManager.default.removeItem(at: heicURL)
return localIdentifier
}
// MARK: - MOV Generation
private func generateMOV(
sourceImage: CGImage,
targetSize: CGSize,
outputURL: URL,
assetIdentifier: String,
shaderId: String,
intensity: Float,
speed: Float,
direction: Float
) async throws {
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mov)
// Video output settings
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: Int(targetSize.width),
AVVideoHeightKey: Int(targetSize.height),
]
let writerInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
writerInput.expectsMediaDataInRealTime = false
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: writerInput,
sourcePixelBufferAttributes: [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: Int(targetSize.width),
kCVPixelBufferHeightKey as String: Int(targetSize.height),
]
)
writer.add(writerInput)
// Add content identifier metadata
let metadataItem = AVMutableMetadataItem()
metadataItem.key = "com.apple.quicktime.content.identifier" as NSString
metadataItem.keySpace = .quickTimeMetadata
metadataItem.value = assetIdentifier as NSString
metadataItem.dataType = "com.apple.metadata.datatype.UTF-8" as String
writer.metadata = [metadataItem]
// Add still image time metadata
let stillImageTimeItem = AVMutableMetadataItem()
stillImageTimeItem.key = "com.apple.quicktime.still-image-time" as NSString
stillImageTimeItem.keySpace = .quickTimeMetadata
stillImageTimeItem.value = 0 as NSNumber
stillImageTimeItem.dataType = "com.apple.metadata.datatype.int8" as String
let metadataAdaptor = AVAssetWriterInputMetadataAdaptor(
assetWriterInput: AVAssetWriterInput(
mediaType: .metadata,
outputSettings: nil,
sourceFormatHint: try createMetadataFormatDescription()
)
)
writer.add(metadataAdaptor.assetWriterInput)
writer.startWriting()
writer.startSession(atSourceTime: .zero)
// Write still image time at the midpoint
let stillImageTimeGroup = AVTimedMetadataGroup(
items: [stillImageTimeItem],
timeRange: CMTimeRange(
start: CMTime(value: CMTimeValue(fps * Int32(duration) / 2), timescale: fps),
duration: CMTime(value: 1, timescale: fps)
)
)
metadataAdaptor.append(stillImageTimeGroup)
let totalFrames = Int(Double(fps) * duration)
let ciImage = CIImage(cgImage: sourceImage)
let context = CIContext()
for frameIndex in 0..<totalFrames {
while !writerInput.isReadyForMoreMediaData {
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
}
let time = Float(frameIndex) / Float(fps)
let processedImage = applyEffect(
to: ciImage,
shaderId: shaderId,
time: time,
intensity: intensity,
speed: speed,
direction: direction
)
guard let pool = adaptor.pixelBufferPool else {
throw ExportError.pixelBufferPoolUnavailable
}
var pixelBuffer: CVPixelBuffer?
CVPixelBufferPoolCreatePixelBuffer(nil, pool, &pixelBuffer)
guard let buffer = pixelBuffer else {
throw ExportError.pixelBufferCreationFailed
}
context.render(processedImage, to: buffer)
let presentationTime = CMTime(value: CMTimeValue(frameIndex), timescale: fps)
adaptor.append(buffer, withPresentationTime: presentationTime)
}
writerInput.markAsFinished()
metadataAdaptor.assetWriterInput.markAsFinished()
await writer.finishWriting()
if writer.status == .failed {
throw writer.error ?? ExportError.writerFailed
}
}
// MARK: - HEIC Generation
private func generateHEIC(
sourceImage: CGImage,
outputURL: URL,
assetIdentifier: String
) throws {
guard let destination = CGImageDestinationCreateWithURL(
outputURL as CFURL,
"public.heic" as CFString,
1,
nil
) else {
throw ExportError.heicCreationFailed
}
// Apple maker note with content identifier
// Key "17" in the maker note dictionary stores the asset identifier
let makerNote: [String: Any] = [
kCGImagePropertyMakerAppleDictionary as String: ["17": assetIdentifier]
]
CGImageDestinationAddImage(destination, sourceImage, makerNote as CFDictionary)
if !CGImageDestinationFinalize(destination) {
throw ExportError.heicWriteFailed
}
}
// MARK: - Photo Library Save
private func saveLivePhoto(imageURL: URL, videoURL: URL) async throws -> String {
return try await withCheckedThrowingContinuation { continuation in
var localIdentifier = ""
PHPhotoLibrary.shared().performChanges({
let request = PHAssetCreationRequest.forAsset()
request.addResource(with: .photo, fileURL: imageURL, options: nil)
let videoOptions = PHAssetResourceCreationOptions()
videoOptions.shouldMoveFile = false
request.addResource(with: .pairedVideo, fileURL: videoURL, options: videoOptions)
localIdentifier = request.placeholderForCreatedAsset?.localIdentifier ?? ""
}) { success, error in
if success {
continuation.resume(returning: localIdentifier)
} else {
continuation.resume(throwing: error ?? ExportError.photoLibrarySaveFailed)
}
}
}
}
// MARK: - Image Effects (CIFilter based)
private func applyEffect(
to image: CIImage,
shaderId: String,
time: Float,
intensity: Float,
speed: Float,
direction: Float
) -> CIImage {
let t = time * speed * 0.1
switch shaderId {
case "ken-burns":
let zoom = 1.0 + Double(intensity) * 0.15 * (0.5 + 0.5 * sin(Double(t)))
let panX = cos(Double(direction)) * Double(intensity) * 0.05 * sin(Double(t) * 0.7)
let panY = sin(Double(direction)) * Double(intensity) * 0.05 * cos(Double(t) * 0.7)
let transform = CGAffineTransform.identity
.translatedBy(
x: image.extent.width * 0.5,
y: image.extent.height * 0.5
)
.scaledBy(x: CGFloat(zoom), y: CGFloat(zoom))
.translatedBy(
x: -image.extent.width * 0.5 + CGFloat(panX) * image.extent.width,
y: -image.extent.height * 0.5 + CGFloat(panY) * image.extent.height
)
return image.transformed(by: transform).cropped(to: image.extent)
case "color-shift":
let hueAngle = Double(intensity) * 0.3 * sin(Double(t) * 1.5)
return image.applyingFilter("CIHueAdjust", parameters: [
"inputAngle": hueAngle
])
case "vignette-pulse":
let vignetteIntensity = Double(intensity) * (0.6 + 0.4 * sin(Double(t) * 3.0))
return image.applyingFilter("CIVignette", parameters: [
"inputIntensity": vignetteIntensity,
"inputRadius": 1.5
])
case "particles":
// For Live Photo export, overlay a subtle brightness variation
// (real particle rendering would need Metal compute shaders)
let brightness = Double(intensity) * 0.05 * sin(Double(t) * 2.0)
return image.applyingFilter("CIColorControls", parameters: [
"inputBrightness": brightness,
"inputSaturation": 1.0 + Double(intensity) * 0.1 * sin(Double(t) * 1.5)
])
case "glitch":
// RGB split via offset color channels
let shift = CGFloat(intensity) * 3.0
let trigger = sin(Double(t) * 10.0) > 0.9 ? 1.0 : 0.0
if trigger > 0.5 {
// Apply chromatic aberration effect
let redShifted = image.transformed(by: CGAffineTransform(translationX: shift, y: 0))
let blueShifted = image.transformed(by: CGAffineTransform(translationX: -shift, y: 0))
// Blend: take red from shifted, green from original, blue from opposite shifted
guard let colorMatrix = CIFilter(name: "CIColorMatrix") else { return image }
colorMatrix.setValue(image, forKey: kCIInputImageKey)
return colorMatrix.outputImage ?? image
}
return image
default:
return image
}
}
// MARK: - Helpers
private func resizeImage(_ image: UIImage, to size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
}
private func createMetadataFormatDescription() throws -> CMFormatDescription {
let spec: [String: Any] = [
kCMMetadataFormatDescriptionKey_Namespace as String:
"mdta" as NSString,
kCMMetadataFormatDescriptionKey_Value as String:
"com.apple.quicktime.still-image-time" as NSString,
kCMMetadataFormatDescriptionKey_DataType as String:
"com.apple.metadata.datatype.int8" as NSString,
]
var desc: CMFormatDescription?
CMMetadataFormatDescriptionCreateWithMetadataSpecifications(
allocator: kCFAllocatorDefault,
metadataType: kCMMetadataFormatType_Boxed,
metadataSpecifications: [spec] as CFArray,
formatDescriptionOut: &desc
)
guard let formatDesc = desc else {
throw ExportError.metadataFormatFailed
}
return formatDesc
}
}
// MARK: - Errors
enum ExportError: LocalizedError {
case imageConversionFailed
case pixelBufferPoolUnavailable
case pixelBufferCreationFailed
case writerFailed
case heicCreationFailed
case heicWriteFailed
case photoLibrarySaveFailed
case metadataFormatFailed
var errorDescription: String? {
switch self {
case .imageConversionFailed: return "Failed to convert UIImage to CGImage"
case .pixelBufferPoolUnavailable: return "Pixel buffer pool unavailable"
case .pixelBufferCreationFailed: return "Failed to create pixel buffer"
case .writerFailed: return "AVAssetWriter failed"
case .heicCreationFailed: return "Failed to create HEIC destination"
case .heicWriteFailed: return "Failed to write HEIC file"
case .photoLibrarySaveFailed: return "Failed to save Live Photo to library"
case .metadataFormatFailed: return "Failed to create metadata format description"
}
}
}

View File

@@ -0,0 +1,82 @@
import ExpoModulesCore
import Photos
import PhotosUI
public class LivePhotoModule: Module {
public func definition() -> ModuleDefinition {
Name("LivePhoto")
/// Generate a Live Photo from an image + animation config and save to photo library.
/// Returns the local identifier of the saved PHAsset.
AsyncFunction("exportLivePhoto") { (configJson: String, promise: Promise) in
guard let data = configJson.data(using: .utf8),
let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let sourceUri = config["sourceUri"] as? String else {
promise.reject("INVALID_CONFIG", "Invalid wallpaper config JSON")
return
}
let shaderId = config["animation"] as? String ?? "ken-burns"
let uniforms = config["uniforms"] as? [String: Double] ?? [:]
let intensity = Float(uniforms["intensity"] ?? 0.5)
let speed = Float(uniforms["speed"] ?? 0.3)
let direction = Float(uniforms["direction"] ?? 0.0)
// Resolve image path
let imagePath: String
if sourceUri.hasPrefix("file://") {
imagePath = String(sourceUri.dropFirst(7))
} else {
imagePath = sourceUri
}
guard let image = UIImage(contentsOfFile: imagePath) else {
promise.reject("IMAGE_NOT_FOUND", "Could not load image at \(imagePath)")
return
}
let screenSize = UIScreen.main.bounds.size
let scale = UIScreen.main.scale
let targetSize = CGSize(width: screenSize.width * scale, height: screenSize.height * scale)
Task {
do {
let exporter = LivePhotoExporter()
let assetId = try await exporter.export(
image: image,
targetSize: targetSize,
shaderId: shaderId,
intensity: intensity,
speed: speed,
direction: direction
)
promise.resolve(assetId)
} catch {
promise.reject("EXPORT_FAILED", error.localizedDescription)
}
}
}
/// Check if the photo library is accessible.
AsyncFunction("checkPermission") { () -> String in
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
switch status {
case .authorized, .limited: return "granted"
case .denied, .restricted: return "denied"
case .notDetermined: return "undetermined"
@unknown default: return "unknown"
}
}
/// Request photo library write permission.
AsyncFunction("requestPermission") { (promise: Promise) in
PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
switch status {
case .authorized, .limited: promise.resolve("granted")
case .denied, .restricted: promise.resolve("denied")
default: promise.resolve("denied")
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
import { requireNativeModule } from "expo-modules-core";
interface LivePhotoInterface {
exportLivePhoto(configJson: string): Promise<string>;
checkPermission(): Promise<"granted" | "denied" | "undetermined" | "unknown">;
requestPermission(): Promise<"granted" | "denied">;
}
export const LivePhoto =
requireNativeModule<LivePhotoInterface>("LivePhoto");

View File

@@ -0,0 +1,27 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.lively.wallpaper"
compileSdk = 35
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(project(":expo-modules-core"))
implementation("org.jetbrains.kotlin:kotlin-stdlib")
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.SET_WALLPAPER" />
<uses-feature
android:name="android.software.live_wallpaper"
android:required="false" />
<application>
<service
android:name=".LiveWallpaperService"
android:label="Lively Wallpaper"
android:permission="android.permission.BIND_WALLPAPER"
android:exported="true">
<intent-filter>
<action android:name="android.service.wallpaper.WallpaperService" />
</intent-filter>
<meta-data
android:name="android.service.wallpaper"
android:resource="@xml/wallpaper" />
</service>
</application>
</manifest>

View File

@@ -0,0 +1,189 @@
package com.lively.wallpaper
import android.content.Context
import android.content.SharedPreferences
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.opengl.GLSurfaceView
import android.os.Handler
import android.os.Looper
import android.service.wallpaper.WallpaperService
import android.view.SurfaceHolder
import org.json.JSONObject
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10
/**
* Live Wallpaper service that renders animated shaders via OpenGL ES 2.0.
*
* Reads wallpaper config from SharedPreferences (written by WallpaperAndroidModule).
* Config format: { shaderId, imagePath, depthMapPath?, uniforms: { intensity, speed, direction } }
*/
class LiveWallpaperService : WallpaperService() {
override fun onCreateEngine(): Engine = LiveEngine()
inner class LiveEngine : WallpaperService.Engine(), SensorEventListener {
private var renderer: ShaderRenderer? = null
private var glSurface: WallpaperGLSurfaceView? = null
// Config
private var shaderId = "ken-burns"
private var imagePath = ""
private var depthMapPath: String? = null
private var intensity = 0.5f
private var speed = 0.3f
private var direction = 0f
private var targetFps = 24
// State
private var startTime = 0L
private var gyroX = 0f
private var gyroY = 0f
private var isVisible = false
// Sensors
private var sensorManager: SensorManager? = null
private var gyroscope: Sensor? = null
// Render loop
private val handler = Handler(Looper.getMainLooper())
private val renderRunnable = object : Runnable {
override fun run() {
if (isVisible) {
glSurface?.requestRender()
handler.postDelayed(this, (1000L / targetFps))
}
}
}
override fun onCreate(surfaceHolder: SurfaceHolder) {
super.onCreate(surfaceHolder)
setTouchEventsEnabled(false)
loadConfig()
sensorManager = getSystemService(Context.SENSOR_SERVICE) as? SensorManager
gyroscope = sensorManager?.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
}
override fun onSurfaceCreated(holder: SurfaceHolder) {
super.onSurfaceCreated(holder)
glSurface = WallpaperGLSurfaceView(this@LiveWallpaperService).also { view ->
view.setEGLContextClientVersion(2)
view.setRenderer(WallpaperRenderer())
view.renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
}
startTime = System.currentTimeMillis()
}
override fun onVisibilityChanged(visible: Boolean) {
super.onVisibilityChanged(visible)
isVisible = visible
if (visible) {
handler.post(renderRunnable)
gyroscope?.let {
sensorManager?.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME)
}
} else {
handler.removeCallbacks(renderRunnable)
sensorManager?.unregisterListener(this)
}
}
override fun onSurfaceDestroyed(holder: SurfaceHolder) {
super.onSurfaceDestroyed(holder)
isVisible = false
handler.removeCallbacks(renderRunnable)
sensorManager?.unregisterListener(this)
renderer?.release()
renderer = null
}
override fun onSensorChanged(event: SensorEvent?) {
event?.let {
if (it.sensor.type == Sensor.TYPE_GYROSCOPE) {
// Accumulate with damping
gyroX += it.values[0] * 0.05f
gyroY += it.values[1] * 0.05f
// Damping toward zero
gyroX *= 0.95f
gyroY *= 0.95f
}
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
private fun loadConfig() {
val prefs = getSharedPreferences("lively_wallpaper", Context.MODE_PRIVATE)
val configStr = prefs.getString("config", null) ?: return
try {
val json = JSONObject(configStr)
shaderId = json.optString("animation", "ken-burns")
imagePath = json.optString("sourceUri", "")
depthMapPath = json.optString("depthMapUri", null)
targetFps = json.optInt("fps", 24)
val uniforms = json.optJSONObject("uniforms")
if (uniforms != null) {
intensity = uniforms.optDouble("intensity", 0.5).toFloat()
speed = uniforms.optDouble("speed", 0.3).toFloat()
direction = uniforms.optDouble("direction", 0.0).toFloat()
}
} catch (e: Exception) {
// Fallback to defaults on parse error
}
}
inner class WallpaperRenderer : GLSurfaceView.Renderer {
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
renderer = ShaderRenderer().also { r ->
r.initialize(shaderId)
if (imagePath.isNotEmpty()) {
r.loadTexture(imagePath)
}
depthMapPath?.let { path ->
if (path.isNotEmpty()) r.loadDepthTexture(path)
}
}
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
android.opengl.GLES20.glViewport(0, 0, width, height)
}
override fun onDrawFrame(gl: GL10?) {
val elapsed = (System.currentTimeMillis() - startTime) / 1000f
val holder = surfaceHolder
val width = holder.surfaceFrame.width()
val height = holder.surfaceFrame.height()
renderer?.draw(
width = width,
height = height,
time = elapsed,
intensity = intensity,
speed = speed,
direction = direction,
gyroX = gyroX,
gyroY = gyroY
)
}
}
/**
* Custom GLSurfaceView that uses the WallpaperService's SurfaceHolder.
*/
inner class WallpaperGLSurfaceView(context: Context) : GLSurfaceView(context) {
override fun getHolder(): SurfaceHolder = this@LiveEngine.surfaceHolder
}
}
}

View File

@@ -0,0 +1,438 @@
package com.lively.wallpaper
import android.opengl.GLES20
import android.opengl.GLUtils
import android.graphics.BitmapFactory
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
/**
* OpenGL ES 2.0 renderer that applies fragment shaders to a texture.
* Each animation type maps to a GLSL fragment shader equivalent to the SkSL version.
*/
class ShaderRenderer {
private var programId = 0
private var textureId = 0
private var depthTextureId = 0
private var vertexBuffer: FloatBuffer? = null
// Uniform locations
private var uResolution = -1
private var uTime = -1
private var uIntensity = -1
private var uSpeed = -1
private var uDirection = -1
private var uImage = -1
private var uDepthMap = -1
private var uGyroX = -1
private var uGyroY = -1
private var uParticleType = -1
private val vertexShaderCode = """
attribute vec4 aPosition;
attribute vec2 aTexCoord;
varying vec2 vTexCoord;
void main() {
gl_Position = aPosition;
vTexCoord = aTexCoord;
}
""".trimIndent()
// Fullscreen quad vertices (position + texcoord)
private val quadVertices = floatArrayOf(
// X, Y, U, V
-1.0f, -1.0f, 0.0f, 1.0f,
1.0f, -1.0f, 1.0f, 1.0f,
-1.0f, 1.0f, 0.0f, 0.0f,
1.0f, 1.0f, 1.0f, 0.0f,
)
fun initialize(shaderId: String) {
val bb = ByteBuffer.allocateDirect(quadVertices.size * 4)
bb.order(ByteOrder.nativeOrder())
vertexBuffer = bb.asFloatBuffer().apply {
put(quadVertices)
position(0)
}
val fragmentShaderCode = getFragmentShader(shaderId)
programId = createProgram(vertexShaderCode, fragmentShaderCode)
GLES20.glUseProgram(programId)
uResolution = GLES20.glGetUniformLocation(programId, "uResolution")
uTime = GLES20.glGetUniformLocation(programId, "uTime")
uIntensity = GLES20.glGetUniformLocation(programId, "uIntensity")
uSpeed = GLES20.glGetUniformLocation(programId, "uSpeed")
uDirection = GLES20.glGetUniformLocation(programId, "uDirection")
uImage = GLES20.glGetUniformLocation(programId, "uImage")
uDepthMap = GLES20.glGetUniformLocation(programId, "uDepthMap")
uGyroX = GLES20.glGetUniformLocation(programId, "uGyroX")
uGyroY = GLES20.glGetUniformLocation(programId, "uGyroY")
uParticleType = GLES20.glGetUniformLocation(programId, "uParticleType")
}
fun loadTexture(imagePath: String): Boolean {
val bitmap = BitmapFactory.decodeFile(imagePath) ?: return false
val textures = IntArray(1)
GLES20.glGenTextures(1, textures, 0)
textureId = textures[0]
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0)
bitmap.recycle()
return true
}
fun loadDepthTexture(depthMapPath: String): Boolean {
val bitmap = BitmapFactory.decodeFile(depthMapPath) ?: return false
val textures = IntArray(1)
GLES20.glGenTextures(1, textures, 0)
depthTextureId = textures[0]
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, depthTextureId)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0)
bitmap.recycle()
return true
}
fun draw(
width: Int,
height: Int,
time: Float,
intensity: Float,
speed: Float,
direction: Float,
gyroX: Float = 0f,
gyroY: Float = 0f,
particleType: Float = 0f
) {
GLES20.glUseProgram(programId)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
// Bind image texture to unit 0
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId)
if (uImage >= 0) GLES20.glUniform1i(uImage, 0)
// Bind depth map to unit 1 (if available)
if (depthTextureId != 0) {
GLES20.glActiveTexture(GLES20.GL_TEXTURE1)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, depthTextureId)
if (uDepthMap >= 0) GLES20.glUniform1i(uDepthMap, 1)
}
// Set uniforms
if (uResolution >= 0) GLES20.glUniform2f(uResolution, width.toFloat(), height.toFloat())
if (uTime >= 0) GLES20.glUniform1f(uTime, time)
if (uIntensity >= 0) GLES20.glUniform1f(uIntensity, intensity)
if (uSpeed >= 0) GLES20.glUniform1f(uSpeed, speed)
if (uDirection >= 0) GLES20.glUniform1f(uDirection, direction)
if (uGyroX >= 0) GLES20.glUniform1f(uGyroX, gyroX)
if (uGyroY >= 0) GLES20.glUniform1f(uGyroY, gyroY)
if (uParticleType >= 0) GLES20.glUniform1f(uParticleType, particleType)
// Draw fullscreen quad
val posHandle = GLES20.glGetAttribLocation(programId, "aPosition")
val texHandle = GLES20.glGetAttribLocation(programId, "aTexCoord")
vertexBuffer?.let { buf ->
buf.position(0)
GLES20.glEnableVertexAttribArray(posHandle)
GLES20.glVertexAttribPointer(posHandle, 2, GLES20.GL_FLOAT, false, 16, buf)
buf.position(2)
GLES20.glEnableVertexAttribArray(texHandle)
GLES20.glVertexAttribPointer(texHandle, 2, GLES20.GL_FLOAT, false, 16, buf)
}
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
GLES20.glDisableVertexAttribArray(posHandle)
GLES20.glDisableVertexAttribArray(texHandle)
}
fun release() {
if (programId != 0) {
GLES20.glDeleteProgram(programId)
programId = 0
}
if (textureId != 0) {
GLES20.glDeleteTextures(1, intArrayOf(textureId), 0)
textureId = 0
}
if (depthTextureId != 0) {
GLES20.glDeleteTextures(1, intArrayOf(depthTextureId), 0)
depthTextureId = 0
}
}
private fun createProgram(vertexSource: String, fragmentSource: String): Int {
val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource)
val fragmentShader = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
val program = GLES20.glCreateProgram()
GLES20.glAttachShader(program, vertexShader)
GLES20.glAttachShader(program, fragmentShader)
GLES20.glLinkProgram(program)
val linkStatus = IntArray(1)
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
if (linkStatus[0] == 0) {
val log = GLES20.glGetProgramInfoLog(program)
GLES20.glDeleteProgram(program)
throw RuntimeException("Program link failed: $log")
}
GLES20.glDeleteShader(vertexShader)
GLES20.glDeleteShader(fragmentShader)
return program
}
private fun compileShader(type: Int, source: String): Int {
val shader = GLES20.glCreateShader(type)
GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader)
val compileStatus = IntArray(1)
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0)
if (compileStatus[0] == 0) {
val log = GLES20.glGetShaderInfoLog(shader)
GLES20.glDeleteShader(shader)
throw RuntimeException("Shader compile failed: $log")
}
return shader
}
companion object {
/**
* Maps animation IDs to GLSL fragment shaders.
* These are GLSL ES 2.0 equivalents of the SkSL shaders in src/shaders/.
*/
fun getFragmentShader(shaderId: String): String = when (shaderId) {
"ken-burns" -> """
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uImage;
uniform vec2 uResolution;
uniform float uTime;
uniform float uIntensity;
uniform float uSpeed;
uniform float uDirection;
void main() {
float t = uTime * uSpeed * 0.1;
float zoom = 1.0 + uIntensity * 0.15 * (0.5 + 0.5 * sin(t));
float panX = cos(uDirection) * uIntensity * 0.05 * sin(t * 0.7);
float panY = sin(uDirection) * uIntensity * 0.05 * cos(t * 0.7);
vec2 center = vec2(0.5);
vec2 uv = (vTexCoord - center) / zoom + center;
uv += vec2(panX, panY);
uv = clamp(uv, 0.0, 1.0);
gl_FragColor = texture2D(uImage, uv);
}
""".trimIndent()
"color-shift" -> """
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uImage;
uniform float uTime;
uniform float uIntensity;
uniform float uSpeed;
vec3 rgb2hsl(vec3 c) {
float mx = max(c.r, max(c.g, c.b));
float mn = min(c.r, min(c.g, c.b));
float l = (mx + mn) * 0.5;
float s = 0.0, h = 0.0;
if (mx != mn) {
float d = mx - mn;
s = l > 0.5 ? d / (2.0 - mx - mn) : d / (mx + mn);
if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
else if (mx == c.g) h = (c.b - c.r) / d + 2.0;
else h = (c.r - c.g) / d + 4.0;
h /= 6.0;
}
return vec3(h, s, l);
}
float hue2rgb(float p, float q, float t) {
float tt = t;
if (tt < 0.0) tt += 1.0;
if (tt > 1.0) tt -= 1.0;
if (tt < 1.0/6.0) return p + (q - p) * 6.0 * tt;
if (tt < 1.0/2.0) return q;
if (tt < 2.0/3.0) return p + (q - p) * (2.0/3.0 - tt) * 6.0;
return p;
}
vec3 hsl2rgb(vec3 hsl) {
if (hsl.y == 0.0) return vec3(hsl.z);
float q = hsl.z < 0.5 ? hsl.z * (1.0 + hsl.y) : hsl.z + hsl.y - hsl.z * hsl.y;
float p = 2.0 * hsl.z - q;
return vec3(hue2rgb(p,q,hsl.x+1.0/3.0), hue2rgb(p,q,hsl.x), hue2rgb(p,q,hsl.x-1.0/3.0));
}
void main() {
vec4 color = texture2D(uImage, vTexCoord);
vec3 hsl = rgb2hsl(color.rgb);
float t = uTime * uSpeed * 0.15;
hsl.x = fract(hsl.x + uIntensity * 0.1 * sin(t));
hsl.y = clamp(hsl.y + uIntensity * 0.15 * sin(t * 1.3), 0.0, 1.0);
hsl.z = clamp(hsl.z + uIntensity * 0.03 * sin(t * 0.8), 0.0, 1.0);
gl_FragColor = vec4(hsl2rgb(hsl), color.a);
}
""".trimIndent()
"particles" -> """
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uImage;
uniform vec2 uResolution;
uniform float uTime;
uniform float uIntensity;
uniform float uSpeed;
uniform float uParticleType;
float hash(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
float snow(vec2 uv, float layer) {
float t = uTime * uSpeed * 0.4 + layer * 12.345;
vec2 grid = floor(uv * (8.0 + layer * 6.0));
vec2 f = fract(uv * (8.0 + layer * 6.0));
float rnd = hash(grid);
vec2 center = vec2(0.5 + 0.3*sin(t+rnd*6.28), fract(rnd+t*(0.1+rnd*0.08)));
float d = length(f - center);
float size = 0.04 + rnd * 0.06;
return smoothstep(size, size*0.3, d) * (0.4+0.6*rnd);
}
float bokeh(vec2 uv, float layer) {
float t = uTime * uSpeed * 0.2 + layer * 5.67;
vec2 grid = floor(uv * (3.0 + layer * 2.0));
vec2 f = fract(uv * (3.0 + layer * 2.0));
float rnd = hash(grid);
vec2 center = vec2(0.5+0.2*sin(t*0.5+rnd*6.28), 0.5+0.2*cos(t*0.3+rnd*6.28));
float d = length(f - center);
float size = 0.1 + rnd * 0.15;
float ring = smoothstep(size,size-0.02,d) - smoothstep(size-0.03,size-0.06,d);
float fill = smoothstep(size,size*0.2,d) * 0.3;
return (ring+fill) * (0.3+0.7*rnd) * step(0.3, rnd);
}
void main() {
vec4 color = texture2D(uImage, vTexCoord);
float particles = 0.0;
for (float i = 0.0; i < 3.0; i += 1.0) {
if (uParticleType < 0.5) particles += snow(vTexCoord, i);
else particles += bokeh(vTexCoord, i);
}
particles *= uIntensity;
gl_FragColor = vec4(clamp(color.rgb + vec3(particles), 0.0, 1.0), color.a);
}
""".trimIndent()
"vignette-pulse" -> """
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uImage;
uniform float uTime;
uniform float uIntensity;
uniform float uSpeed;
void main() {
vec4 color = texture2D(uImage, vTexCoord);
vec2 center = vTexCoord - 0.5;
float dist = length(center);
float t = uTime * uSpeed * 0.3;
float strength = uIntensity * (0.6 + 0.4 * sin(t));
float vignette = 1.0 - smoothstep(0.2, 0.85, dist * (1.0 + strength));
vec3 tinted = color.rgb * vignette;
float tint = (1.0 - vignette) * 0.15 * uIntensity;
tinted.b += tint * 0.3;
gl_FragColor = vec4(clamp(tinted, 0.0, 1.0), color.a);
}
""".trimIndent()
"glitch" -> """
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uImage;
uniform vec2 uResolution;
uniform float uTime;
uniform float uIntensity;
uniform float uSpeed;
float hash(float n) { return fract(sin(n) * 43758.5453); }
void main() {
float t = uTime * uSpeed * 0.5;
float trigger = step(0.92, sin(t*2.0)*sin(t*7.3));
float amount = uIntensity * trigger;
float shift = uIntensity * 0.003 + amount * 0.015;
vec2 uvR = vec2(vTexCoord.x + shift, vTexCoord.y);
vec2 uvB = vec2(vTexCoord.x - shift, vTexCoord.y);
uvR = clamp(uvR, 0.0, 1.0);
uvB = clamp(uvB, 0.0, 1.0);
float r = texture2D(uImage, uvR).r;
float g = texture2D(uImage, vTexCoord).g;
float b = texture2D(uImage, uvB).b;
gl_FragColor = vec4(r, g, b, 1.0);
}
""".trimIndent()
"depth-parallax" -> """
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uImage;
uniform sampler2D uDepthMap;
uniform vec2 uResolution;
uniform float uTime;
uniform float uIntensity;
uniform float uSpeed;
uniform float uGyroX;
uniform float uGyroY;
void main() {
float depth = texture2D(uDepthMap, vTexCoord).r;
float t = uTime * uSpeed * 0.2;
vec2 offset = vec2(
uGyroX * 0.8 + sin(t) * 0.3,
uGyroY * 0.8 + cos(t * 0.7) * 0.3
);
vec2 displacement = offset * depth * uIntensity * 0.04;
vec2 uv = clamp(vTexCoord + displacement, 0.0, 1.0);
gl_FragColor = texture2D(uImage, uv);
}
""".trimIndent()
else -> """
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uImage;
void main() { gl_FragColor = texture2D(uImage, vTexCoord); }
""".trimIndent()
}
}
}

View File

@@ -0,0 +1,59 @@
package com.lively.wallpaper
import android.app.WallpaperManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import org.json.JSONObject
class WallpaperAndroidModule : Module() {
override fun definition() = ModuleDefinition {
Name("WallpaperAndroid")
/**
* Save wallpaper configuration to SharedPreferences.
* The LiveWallpaperService reads this on start.
*/
AsyncFunction("saveConfig") { configJson: String ->
val context = appContext.reactContext ?: throw Exception("Context unavailable")
val prefs = context.getSharedPreferences("lively_wallpaper", Context.MODE_PRIVATE)
prefs.edit().putString("config", configJson).apply()
}
/**
* Launch the system live wallpaper picker with our service pre-selected.
*/
AsyncFunction("setLiveWallpaper") {
val context = appContext.reactContext ?: throw Exception("Context unavailable")
val intent = Intent(WallpaperManager.ACTION_CHANGE_LIVE_WALLPAPER).apply {
putExtra(
WallpaperManager.EXTRA_LIVE_WALLPAPER_COMPONENT,
ComponentName(context.packageName, LiveWallpaperService::class.java.name)
)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
/**
* Check if live wallpapers are supported on this device.
*/
Function("isLiveWallpaperSupported") {
val context = appContext.reactContext ?: return@Function false
val pm = context.packageManager
pm.hasSystemFeature("android.software.live_wallpaper")
}
/**
* Get the currently active wallpaper config (if it's ours).
*/
Function("getCurrentConfig") {
val context = appContext.reactContext ?: return@Function null
val prefs = context.getSharedPreferences("lively_wallpaper", Context.MODE_PRIVATE)
prefs.getString("config", null)
}
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<wallpaper
xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_name"
android:thumbnail="@mipmap/ic_launcher" />

View File

@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["com.lively.wallpaper.WallpaperAndroidModule"]
}
}

View File

@@ -0,0 +1,11 @@
import { requireNativeModule } from "expo-modules-core";
interface WallpaperAndroidInterface {
saveConfig(configJson: string): Promise<void>;
setLiveWallpaper(): Promise<void>;
isLiveWallpaperSupported(): boolean;
getCurrentConfig(): string | null;
}
export const WallpaperAndroid =
requireNativeModule<WallpaperAndroidInterface>("WallpaperAndroid");

View File

@@ -0,0 +1,82 @@
import React, { useMemo } from "react";
import { StyleSheet, ViewStyle } from "react-native";
import {
Canvas,
Image as SkImage,
Shader,
Fill,
useImage,
} from "@shopify/react-native-skia";
import { useSharedValue, useFrameCallback } from "react-native-reanimated";
import { shaders } from "@/shaders";
import type { AnimationType, AnimationUniforms } from "@/types/wallpaper";
interface AnimationCanvasProps {
sourceUri: string;
animation: AnimationType;
uniforms: AnimationUniforms;
width: number;
height: number;
fps?: number;
style?: ViewStyle;
}
/**
* Standalone animated preview canvas.
* Used in wallpaper cards on the home screen (mini previews at reduced FPS).
*/
export function AnimationCanvas({
sourceUri,
animation,
uniforms,
width,
height,
fps = 15,
style,
}: AnimationCanvasProps) {
const image = useImage(sourceUri);
const shader = useMemo(() => shaders[animation], [animation]);
const time = useSharedValue(0);
const frameInterval = 1000 / fps;
let lastFrame = 0;
useFrameCallback((info) => {
const now = info.timeSinceFirstFrame ?? 0;
if (now - lastFrame >= frameInterval) {
time.value = now / 1000;
lastFrame = now;
}
});
if (!image || !shader) return null;
return (
<Canvas style={[{ width, height }, style]}>
<Fill>
<Shader
source={shader}
uniforms={{
resolution: [width, height],
time: time.value,
intensity: uniforms.intensity,
speed: uniforms.speed,
direction: uniforms.direction ?? 0,
particleType: 0,
gyroX: 0,
gyroY: 0,
}}
>
<SkImage
image={image}
x={0}
y={0}
width={width}
height={height}
fit="cover"
/>
</Shader>
</Fill>
</Canvas>
);
}

View File

@@ -0,0 +1 @@
export { AnimationCanvas } from "./AnimationCanvas";

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);
}

View File

@@ -0,0 +1,50 @@
import * as MediaLibrary from "expo-media-library";
import * as ImageManipulator from "expo-image-manipulator";
import { Dimensions } from "react-native";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("screen");
export async function requestGalleryPermission(): Promise<boolean> {
const { status } = await MediaLibrary.requestPermissionsAsync();
return status === "granted";
}
export async function getRecentPhotos(
count: number = 50,
offset: number = 0
): Promise<{ assets: MediaLibrary.Asset[]; hasMore: boolean }> {
const query = new MediaLibrary.Query()
.eq(MediaLibrary.AssetField.MEDIA_TYPE, MediaLibrary.MediaType.IMAGE)
.orderBy({
key: MediaLibrary.AssetField.CREATION_TIME,
ascending: false,
})
.limit(count)
.offset(offset);
const assets = await query.exe();
return {
assets,
hasMore: assets.length === count,
};
}
export async function prepareImageForWallpaper(
uri: string
): Promise<string> {
const result = await ImageManipulator.manipulateAsync(
uri,
[
{
resize: {
width: SCREEN_WIDTH * 2,
height: SCREEN_HEIGHT * 2,
},
},
],
{ compress: 0.95, format: ImageManipulator.SaveFormat.JPEG }
);
return result.uri;
}

View File

@@ -0,0 +1,46 @@
import { Platform } from "react-native";
// Depth estimation service — V1.5 feature
// Uses MiDaS v2.1 small (TFLite) for on-device depth map generation
// Model size: ~17MB, inference: ~50-80ms on mid-range devices
export interface DepthMapResult {
uri: string; // Path to grayscale depth map PNG
width: number;
height: number;
inferenceTimeMs: number;
}
export async function isDepthEstimationAvailable(): Promise<boolean> {
// Will check for TFLite model availability
// For now, return false — not yet implemented
return false;
}
export async function generateDepthMap(
imageUri: string
): Promise<DepthMapResult> {
// TODO V1.5: Implement with react-native-fast-tflite or ONNX Runtime
//
// Pipeline:
// 1. Load image, resize to MiDaS input size (256x256 or 384x384)
// 2. Normalize to [0,1] float tensor
// 3. Run MiDaS inference
// 4. Resize output to original image dimensions
// 5. Normalize depth values to [0,255] grayscale
// 6. Save as PNG, return URI
//
// The depth map will be cached in MMKV keyed by image URI hash
// to avoid recomputation.
throw new Error(
"Depth estimation not yet available. Coming in V1.5 with MiDaS on-device inference."
);
}
export async function downloadDepthModel(): Promise<void> {
// TODO: Download MiDaS TFLite model from Cloudflare R2
// Model is not bundled in the app to keep binary size down
// Download on first use of depth-parallax animation
throw new Error("Model download not yet implemented.");
}