-
-
Notifications
You must be signed in to change notification settings - Fork 34.3k
Description
** // App.js
import React, { useState, useRef } from 'react';
import {
SafeAreaView,
View,
Text,
TouchableOpacity,
StyleSheet,
TextInput,
FlatList,
Alert,
} from 'react-native';
import { Audio } from 'expo-av';
import * as Speech from 'expo-speech';
import { StatusBar } from 'expo-status-bar';
// ---- Simple avatar presets (no extra files needed) ----
const AVATAR_PRESETS = [
{
id: 'shadow_male',
skinTone: '#F2C6A0',
outfitColor: '#10B981',
backgroundColor: '#111827',
label: 'Shadow Male',
},
{
id: 'shadow_female',
skinTone: '#D78C5C',
outfitColor: '#EC4899',
backgroundColor: '#1F2937',
label: 'Shadow Female',
},
{
id: 'shadow_neutral',
skinTone: '#9CA3AF',
outfitColor: '#6366F1',
backgroundColor: '#111827',
label: 'Shadow Neutral',
},
{
id: 'cartoon_boy',
skinTone: '#FCD34D',
outfitColor: '#3B82F6',
backgroundColor: '#0F172A',
label: 'Cartoon Boy',
},
{
id: 'cartoon_girl',
skinTone: '#F9A8D4',
outfitColor: '#8B5CF6',
backgroundColor: '#111827',
label: 'Cartoon Girl',
},
];
const DEFAULT_AVATAR = AVATAR_PRESETS[2]; // shadow_neutral
// ---- Simple AvatarPicker inside same file ----
function AvatarPicker({ value, onChange }) {
return (
Choose Your Look
{AVATAR_PRESETS.map((preset) => {
const selected = preset.id === value.id;
return (
<TouchableOpacity
key={preset.id}
style={[
styles.avatarBox,
{ backgroundColor: preset.backgroundColor },
selected && styles.avatarSelected,
]}
onPress={() => onChange(preset)}
>
<View
style={[
styles.avatarHead,
{ backgroundColor: preset.skinTone },
]}
/>
<View
style={[
styles.avatarBody,
{ backgroundColor: preset.outfitColor },
]}
/>
{preset.label}
);
})}
);
}
// ---- Main App ----
export default function App() {
const [screen, setScreen] = useState('home'); // home | audio | avatar | preview | projects
const [mode, setMode] = useState('tiktok'); // tiktok | youtube | shorts
const [aspectRatio, setAspectRatio] = useState('9:16');
const [scriptText, setScriptText] = useState('');
const [audioUri, setAudioUri] = useState(null);
const [captionsEnabled, setCaptionsEnabled] = useState(true);
const [avatar, setAvatar] = useState(DEFAULT_AVATAR);
const [projects, setProjects] = useState([]);
const recordingRef = useRef(null);
const [isRecording, setIsRecording] = useState(false);
const soundRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(false);
// ---- Helpers ----
const resetCurrentProject = () => {
setMode('tiktok');
setAspectRatio('9:16');
setScriptText('');
setAudioUri(null);
setCaptionsEnabled(true);
setAvatar(DEFAULT_AVATAR);
};
const startNewProject = (chosenMode) => {
resetCurrentProject();
setMode(chosenMode);
setAspectRatio(chosenMode === 'youtube' ? '16:9' : '9:16');
setScreen('audio');
};
// ---- Audio Recording ----
const startRecording = async () => {
try {
const permission = await Audio.requestPermissionsAsync();
if (!permission.granted) {
Alert.alert('Permission needed', 'Please allow microphone access.');
return;
}
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
});
const recording = new Audio.Recording();
await recording.prepareToRecordAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY
);
await recording.startAsync();
recordingRef.current = recording;
setIsRecording(true);
} catch (e) {
console.log(e);
Alert.alert('Error', 'Could not start recording.');
}
};
const stopRecording = async () => {
try {
const recording = recordingRef.current;
if (!recording) return;
await recording.stopAndUnloadAsync();
const uri = recording.getURI();
setIsRecording(false);
recordingRef.current = null;
if (uri) {
setAudioUri(uri);
}
} catch (e) {
console.log(e);
Alert.alert('Error', 'Could not stop recording.');
}
};
const handleContinueFromAudio = () => {
if (!audioUri && !scriptText.trim()) {
Alert.alert('Missing content', 'Please record or type something first.');
return;
}
setScreen('avatar');
};
const handleGenerateProject = () => {
const newProject = {
id: Date.now().toString(),
title:
mode === 'tiktok'
? 'TikTok Video'
: mode === 'shorts'
? 'YouTube Short'
: 'YouTube Video',
mode,
aspectRatio,
scriptText: scriptText.trim() || null,
audioUri,
avatar,
captionsEnabled,
createdAt: new Date().toISOString(),
};
setProjects((prev) => [newProject, ...prev]);
setScreen('preview');
};
// ---- Preview audio playback ----
const stopSoundIfAny = async () => {
if (soundRef.current) {
try {
await soundRef.current.stopAsync();
await soundRef.current.unloadAsync();
} catch (e) {
// ignore
}
soundRef.current = null;
}
};
const handlePlayPreview = async () => {
if (isPlaying) {
await stopSoundIfAny();
setIsPlaying(false);
return;
}
const latest = projects[0];
if (!latest) return;
try {
setIsPlaying(true);
if (latest.audioUri) {
// Play recorded audio
const { sound } = await Audio.Sound.createAsync(
{ uri: latest.audioUri },
{ shouldPlay: true }
);
soundRef.current = sound;
sound.setOnPlaybackStatusUpdate((status) => {
if (status.didJustFinish) {
setIsPlaying(false);
}
});
} else if (latest.scriptText) {
// Use text-to-speech
Speech.speak(latest.scriptText, {
onDone: () => setIsPlaying(false),
onStopped: () => setIsPlaying(false),
rate: 1.0,
});
} else {
Alert.alert('No audio', 'Please record or type something first.');
setIsPlaying(false);
}
} catch (e) {
console.log(e);
Alert.alert('Error', 'Could not play audio.');
setIsPlaying(false);
}
};
// ---- "Export" instructions (screen recording) ----
const handleExport = () => {
Alert.alert(
'How to save your faceless video',
'1. Turn on screen recording on your phone.\n' +
'2. Come back to this app.\n' +
'3. Start recording your screen.\n' +
'4. Tap PLAY on the preview so your avatar and audio run.\n' +
'5. Stop screen recording.\n\n' +
'You now have a ready video you can upload to TikTok or YouTube—without showing your face.'
);
};
// ---- Screens ----
const renderHome = () => (
Faceless Video Creator
Make TikTok & YouTube videos with your voice, without showing your face.
<TouchableOpacity
style={styles.primaryButton}
onPress={() => startNewProject('tiktok')}
>
<Text style={styles.primaryButtonText}>New TikTok Video (9:16)</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.primaryButton}
onPress={() => startNewProject('shorts')}
>
<Text style={styles.primaryButtonText}>New YouTube Short (9:16)</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => startNewProject('youtube')}
>
<Text style={styles.secondaryButtonText}>
New YouTube Video (16:9)
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.secondaryButton, { marginTop: 24 }]}
onPress={() => setScreen('projects')}
>
<Text style={styles.secondaryButtonText}>My Projects</Text>
</TouchableOpacity>
</View>
);
const renderAudioScreen = () => (
Step 1: Add Your Voice or Text
You can talk or type. The app will play it over your avatar.
<View style={styles.card}>
<Text style={styles.sectionTitle}>Record Your Voice</Text>
<TouchableOpacity
style={[
styles.recordButton,
isRecording && { backgroundColor: '#DC2626' },
]}
onPress={isRecording ? stopRecording : startRecording}
>
<Text style={styles.recordButtonText}>
{isRecording ? 'Stop Recording' : 'Tap to Record'}
</Text>
</TouchableOpacity>
{audioUri && (
<Text style={styles.goodText}>✅ Voice recorded and ready.</Text>
)}
</View>
<View style={styles.card}>
<Text style={styles.sectionTitle}>Or Type What You Want to Say</Text>
<TextInput
placeholder="Type your script here..."
style={styles.textInput}
multiline
value={scriptText}
onChangeText={setScriptText}
/>
<Text style={styles.smallHint}>
If you don’t record audio, the app will use a robot voice (TTS).
</Text>
</View>
<View style={styles.footerRow}>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => setScreen('home')}
>
<Text style={styles.secondaryButtonText}>Back</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.primaryButton}
onPress={handleContinueFromAudio}
>
<Text style={styles.primaryButtonText}>Continue</Text>
</TouchableOpacity>
</View>
</View>
);
const renderAvatarScreen = () => (
Step 2: Choose How You Look
Your real face never shows. Only this avatar appears in the video.
<AvatarPicker value={avatar} onChange={setAvatar} />
<View style={[styles.card, { marginTop: 24 }]}>
<Text style={styles.sectionTitle}>Captions</Text>
<View style={styles.rowBetween}>
<Text style={styles.caption}>Show text on screen</Text>
<TouchableOpacity
onPress={() => setCaptionsEnabled((c) => !c)}
style={[
styles.toggle,
captionsEnabled && styles.toggleOn,
]}
>
<View
style={[
styles.toggleKnob,
captionsEnabled && { alignSelf: 'flex-end' },
]}
/>
</TouchableOpacity>
</View>
</View>
<View style={styles.footerRow}>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => setScreen('audio')}
>
<Text style={styles.secondaryButtonText}>Back</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.primaryButton}
onPress={handleGenerateProject}
>
<Text style={styles.primaryButtonText}>Preview</Text>
</TouchableOpacity>
</View>
</View>
);
const renderPreviewScreen = () => {
const latest = projects[0];
if (!latest) {
return (
No project yet
<TouchableOpacity
style={styles.primaryButton}
onPress={() => setScreen('home')}
>
Back to Home
);
}
const ratio = latest.aspectRatio === '9:16' ? 9 / 16 : 16 / 9;
return (
<View style={styles.screen}>
<Text style={styles.heading}>Step 3: Preview</Text>
<Text style={styles.caption}>
This is what your faceless video looks like. Use screen recording to
save it as a video.
</Text>
<View
style={[
styles.previewBox,
{ aspectRatio: ratio, backgroundColor: latest.avatar.backgroundColor },
]}
>
<View
style={[
styles.previewHead,
{ backgroundColor: latest.avatar.skinTone },
]}
/>
<View
style={[
styles.previewBody,
{ backgroundColor: latest.avatar.outfitColor },
]}
/>
{latest.captionsEnabled && (
<View style={styles.captionsBox}>
<Text style={styles.captionsText}>
{latest.scriptText
? latest.scriptText.slice(0, 100) + '...'
: 'Your spoken words will show here as captions.'}
</Text>
</View>
)}
</View>
<TouchableOpacity
style={[
styles.primaryButton,
{ marginTop: 16, flexDirection: 'row', justifyContent: 'center' },
]}
onPress={handlePlayPreview}
>
<Text style={styles.primaryButtonText}>
{isPlaying ? 'Stop' : 'Play with Audio'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.secondaryButton}
onPress={handleExport}
>
<Text style={styles.secondaryButtonText}>
How do I save this as a video?
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.secondaryButton, { marginTop: 8 }]}
onPress={() => setScreen('home')}
>
<Text style={styles.secondaryButtonText}>Done</Text>
</TouchableOpacity>
</View>
);
};
const renderProjectsScreen = () => (
My Projects
{projects.length === 0 ? (
No projects yet. Start on the home screen.
) : (
<FlatList
data={projects}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
{item.title}
Mode: {item.mode.toUpperCase()} • Aspect: {item.aspectRatio}
Captions: {item.captionsEnabled ? 'On' : 'Off'}
)}
/>
)}
<TouchableOpacity
style={[styles.secondaryButton, { marginTop: 16 }]}
onPress={() => setScreen('home')}
>
<Text style={styles.secondaryButtonText}>Back</Text>
</TouchableOpacity>
</View>
);
return (
{screen === 'home' && renderHome()}
{screen === 'audio' && renderAudioScreen()}
{screen === 'avatar' && renderAvatarScreen()}
{screen === 'preview' && renderPreviewScreen()}
{screen === 'projects' && renderProjectsScreen()}
);
}
// ---- Styles ----
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F9FAFB',
},
screen: {
flex: 1,
padding: 16,
},
appTitle: {
fontSize: 26,
fontWeight: '800',
marginBottom: 8,
color: '#111827',
},
subtitle: {
fontSize: 15,
color: '#6B7280',
marginBottom: 24,
},
heading: {
fontSize: 22,
fontWeight: '700',
marginBottom: 8,
color: '#111827',
},
caption: {
fontSize: 14,
color: '#6B7280',
marginBottom: 12,
},
primaryButton: {
backgroundColor: '#111827',
paddingVertical: 14,
paddingHorizontal: 18,
borderRadius: 999,
alignItems: 'center',
marginTop: 10,
},
primaryButtonText: {
color: '#F9FAFB',
fontWeight: '600',
fontSize: 15,
},
secondaryButton: {
borderWidth: 1,
borderColor: '#111827',
paddingVertical: 12,
paddingHorizontal: 18,
borderRadius: 999,
alignItems: 'center',
marginTop: 10,
},
secondaryButtonText: {
color: '#111827',
fontWeight: '500',
fontSize: 14,
},
card: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 14,
marginTop: 10,
shadowColor: '#000',
shadowOpacity: 0.03,
shadowRadius: 5,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
color: '#111827',
},
recordButton: {
marginTop: 8,
borderRadius: 999,
paddingVertical: 12,
alignItems: 'center',
backgroundColor: '#111827',
},
recordButtonText: {
color: '#F9FAFB',
fontWeight: '600',
},
goodText: {
marginTop: 8,
fontSize: 13,
color: '#16A34A',
},
textInput: {
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 12,
minHeight: 80,
padding: 10,
fontSize: 14,
textAlignVertical: 'top',
backgroundColor: '#F9FAFB',
},
smallHint: {
marginTop: 6,
fontSize: 12,
color: '#9CA3AF',
},
footerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 8,
marginTop: 24,
},
rowBetween: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
toggle: {
width: 46,
height: 26,
borderRadius: 13,
backgroundColor: '#E5E7EB',
padding: 3,
justifyContent: 'center',
},
toggleOn: {
backgroundColor: '#22C55E',
},
toggleKnob: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: '#FFFFFF',
alignSelf: 'flex-start',
},
previewBox: {
width: '100%',
borderRadius: 16,
marginTop: 16,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
previewHead: {
width: 70,
height: 70,
borderRadius: 35,
marginBottom: 6,
},
previewBody: {
width: 80,
height: 90,
borderRadius: 12,
},
captionsBox: {
position: 'absolute',
bottom: 12,
left: 12,
right: 12,
padding: 6,
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 8,
},
captionsText: {
color: '#F9FAFB',
fontSize: 13,
textAlign: 'center',
},
projectItem: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
padding: 10,
marginTop: 10,
},
projectTitle: {
fontSize: 15,
fontWeight: '600',
color: '#111827',
},
projectMeta: {
fontSize: 13,
color: '#6B7280',
marginTop: 2,
},
avatarRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
avatarBox: {
width: '30%',
borderRadius: 12,
paddingVertical: 10,
alignItems: 'center',
marginBottom: 10,
},
avatarSelected: {
borderWidth: 3,
borderColor: '#22C55E',
},
avatarHead: {
width: 40,
height: 40,
borderRadius: 20,
marginBottom: 6,
},
avatarBody: {
width: 50,
height: 55,
borderRadius: 8,
},
avatarLabel: {
fontSize: 11,
color: '#F9FAFB',
marginTop: 4,
},
});**