Compare commits
10 Commits
bf9649d8fd
...
deb672d139
| 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/
|
||||
.expo/
|
||||
android/
|
||||
ios/
|
||||
/android/
|
||||
/ios/
|
||||
dist/
|
||||
.env
|
||||
*.jks
|
||||
@@ -11,3 +11,4 @@ dist/
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
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