Skip to content

expo init faceless-video-app cd faceless-video-app #60944

@Magdalahe

Description

@Magdalahe

** // 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,
},
});**

Metadata

Metadata

Assignees

No one assigned

    Labels

    invalidIssues and PRs that are invalid.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions