Compare commits
10 Commits
bf9649d8fd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deb672d139 | ||
|
|
187d3391d0 | ||
|
|
9b90e6a4a1 | ||
|
|
84809378d1 | ||
|
|
0e1716738e | ||
|
|
c3073b3f70 | ||
|
|
fbf8094281 | ||
|
|
5cddb440e6 | ||
|
|
1a2fa0ffb5 | ||
|
|
da5602f3b4 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
44
app/(tabs)/_layout.tsx
Normal 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
60
app/(tabs)/explore.tsx
Normal 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
232
app/(tabs)/index.tsx
Normal 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
242
app/(tabs)/profile.tsx
Normal 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
48
app/_layout.tsx
Normal 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
308
app/editor/[id].tsx
Normal 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
266
app/picker.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
6
modules/livephoto-ios/expo-module.config.json
Normal file
6
modules/livephoto-ios/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["ios"],
|
||||||
|
"ios": {
|
||||||
|
"modules": ["LivePhotoModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
379
modules/livephoto-ios/ios/LivePhotoExporter.swift
Normal file
379
modules/livephoto-ios/ios/LivePhotoExporter.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
modules/livephoto-ios/ios/LivePhotoModule.swift
Normal file
82
modules/livephoto-ios/ios/LivePhotoModule.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
modules/livephoto-ios/src/LivePhotoModule.ts
Normal file
10
modules/livephoto-ios/src/LivePhotoModule.ts
Normal 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");
|
||||||
27
modules/wallpaper-android/android/build.gradle.kts
Normal file
27
modules/wallpaper-android/android/build.gradle.kts
Normal 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")
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
6
modules/wallpaper-android/expo-module.config.json
Normal file
6
modules/wallpaper-android/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["android"],
|
||||||
|
"android": {
|
||||||
|
"modules": ["com.lively.wallpaper.WallpaperAndroidModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
modules/wallpaper-android/src/WallpaperAndroidModule.ts
Normal file
11
modules/wallpaper-android/src/WallpaperAndroidModule.ts
Normal 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");
|
||||||
82
src/components/animation/AnimationCanvas.tsx
Normal file
82
src/components/animation/AnimationCanvas.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/animation/index.ts
Normal file
1
src/components/animation/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AnimationCanvas } from "./AnimationCanvas";
|
||||||
50
src/hooks/useAnimation.ts
Normal file
50
src/hooks/useAnimation.ts
Normal 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
43
src/hooks/useGyroscope.ts
Normal 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 };
|
||||||
|
}
|
||||||
147
src/hooks/useWallpaperExport.ts
Normal file
147
src/hooks/useWallpaperExport.ts
Normal 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);
|
||||||
|
}
|
||||||
50
src/services/media/gallery.service.ts
Normal file
50
src/services/media/gallery.service.ts
Normal 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;
|
||||||
|
}
|
||||||
46
src/services/ml/depth.service.ts
Normal file
46
src/services/ml/depth.service.ts
Normal 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.");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user