[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

Major update

Sounds
Trending videos
Better search screen

C3B 3113c52c b6e86637

+495 -16
+2 -1
app/(tabs)/CreateScreen.tsx
··· 1 + import { openVideoFromLocalPathExample } from '@/components/Creation/ShowVideoEditor'; 1 2 import { Ionicons } from '@expo/vector-icons'; 2 3 import { CameraView, CameraType, useCameraPermissions } from 'expo-camera'; 3 4 import { useState } from 'react'; ··· 31 32 <TouchableOpacity style={styles.button} onPress={toggleCameraFacing}> 32 33 <Ionicons name="camera-reverse" size={32} color="white" /> 33 34 </TouchableOpacity> 34 - <TouchableOpacity style={styles.button} onPress={() => {}}> 35 + <TouchableOpacity style={styles.button} onPress={() => {openVideoFromLocalPathExample()}}> 35 36 <Ionicons name="ellipse" size={64} color="white" /> 36 37 </TouchableOpacity> 37 38 <TouchableOpacity style={styles.button} onPress={() => {}}>
+21
app/(tabs)/SearchScreen.tsx
··· 10 10 import { fetchTrendingPosts } from '@/api/feedServices'; 11 11 import { getProfile } from '@/api/profileServices'; 12 12 import { useRouter } from 'expo-router'; 13 + import VideoRowCategory from '@/components/Search/VideoRowCategory'; 14 + import SoundRowsCategory, { SoundProps } from '@/components/Search/SoundRowsCategory'; 15 + import { trendingSounds } from '@/components/Search/mockSoundsData'; 16 + import { Ionicons, FontAwesome5, MaterialCommunityIcons } from '@expo/vector-icons'; 13 17 14 18 // Pad posts so that the grid always has complete rows 15 19 function padPostsWithPlaceholders( ··· 62 66 initialIndex: index.toString(), 63 67 }, 64 68 }); 69 + } 70 + 71 + function handleSoundPress(sound: SoundProps) { 72 + console.log('Sound pressed:', sound.title); 73 + // Future implementation: play sound or navigate to sound details 65 74 } 66 75 67 76 useEffect(() => { ··· 135 144 {featuredUser && ( 136 145 <FeaturedProfile user={featuredUser} isFollowing={false} onFollow={() => {}} /> 137 146 )} 147 + <VideoRowCategory 148 + icon={<Ionicons name="flame" size={24} color={Colors[colorScheme ?? 'light'].text} />} 149 + title="Trending" 150 + videos={videoData} 151 + onVideoPress={handleOpenProfileFeed} 152 + /> 153 + <SoundRowsCategory 154 + icon={<MaterialCommunityIcons name="music-circle" size={24} color={Colors[colorScheme ?? 'light'].text} />} 155 + title="Sounds" 156 + sounds={trendingSounds} 157 + onSoundPress={handleSoundPress} 158 + /> 138 159 <View style={styles.videoGrid}> 139 160 {paddedVideoData.map((post, index) => { 140 161 const key = post.uri || `fallback-${index}`;
+64
components/Creation/ShowVideoEditor.tsx
··· 1 + import { Platform, Alert } from 'react-native'; 2 + import { ios_license, android_license } from './vesdk_license'; 3 + 4 + // Define type for VESDK 5 + interface VideoEditorSDK { 6 + unlockWithLicense: (license: string) => Promise<void>; 7 + openEditor: (video: any) => Promise<any>; 8 + } 9 + 10 + // Try to get VESDK dynamically to avoid immediate reference errors 11 + let VESDK: VideoEditorSDK | null = null; 12 + 13 + // Function to initialize VESDK safely 14 + const initVESDK = async () => { 15 + if (VESDK === null) { 16 + try { 17 + const VESDKModule = require('react-native-videoeditorsdk'); 18 + VESDK = VESDKModule.VESDK; 19 + } catch (error) { 20 + console.error('Failed to import VESDK:', error); 21 + return false; 22 + } 23 + } 24 + return !!VESDK; 25 + }; 26 + 27 + export const openVideoFromLocalPathExample = async (): Promise<void> => { 28 + try { 29 + // Initialize VESDK first 30 + const isSDKAvailable = await initVESDK(); 31 + 32 + if (!isSDKAvailable || !VESDK) { 33 + throw new Error('Video Editor SDK could not be initialized'); 34 + } 35 + 36 + // Get the license based on platform 37 + const license = Platform.OS === 'ios' ? ios_license : android_license; 38 + 39 + // Unlock with license 40 + await VESDK.unlockWithLicense(license); 41 + 42 + // Add a video from the assets directory 43 + const video = require('./cat.MOV'); 44 + 45 + // Open the video editor 46 + const result = await VESDK.openEditor(video); 47 + 48 + if (result != null) { 49 + // The user exported a new video successfully 50 + console.log(result?.video); 51 + } else { 52 + // The user tapped on cancel 53 + return; 54 + } 55 + } catch (error: any) { 56 + console.error('Video Editor Error:', error); 57 + Alert.alert( 58 + "Error", 59 + `Failed to open video editor: ${error.message || String(error)}`, 60 + [{ text: "OK" }] 61 + ); 62 + } 63 + }; 64 +
components/Creation/cat.MOV

This is a binary file and will not be displayed.

+83
components/Search/MusicDisplay.tsx
··· 1 + import React from 'react'; 2 + import { View, StyleSheet, TouchableOpacity, Image } from 'react-native'; 3 + import { ThemedText } from '@/components/ThemedText'; 4 + import { SoundProps } from './SoundRowsCategory'; 5 + 6 + interface MusicDisplayProps { 7 + sound: SoundProps; 8 + onPress: (sound: SoundProps) => void; 9 + } 10 + 11 + const MusicDisplay: React.FC<MusicDisplayProps> = ({ sound, onPress }) => { 12 + return ( 13 + <TouchableOpacity 14 + style={styles.container} 15 + onPress={() => onPress(sound)} 16 + activeOpacity={0.7} 17 + > 18 + <View style={styles.content}> 19 + <Image 20 + source={{ uri: sound.coverImage }} 21 + style={styles.image} 22 + resizeMode="cover" 23 + /> 24 + <View style={styles.textContainer}> 25 + <ThemedText 26 + style={styles.title} 27 + numberOfLines={1} 28 + > 29 + {sound.title} 30 + </ThemedText> 31 + <ThemedText 32 + style={styles.artist} 33 + numberOfLines={1} 34 + > 35 + {sound.artist} 36 + </ThemedText> 37 + </View> 38 + </View> 39 + </TouchableOpacity> 40 + ); 41 + }; 42 + 43 + const styles = StyleSheet.create({ 44 + container: { 45 + marginHorizontal: 6, 46 + marginVertical: 4, 47 + borderRadius: 40, 48 + backgroundColor: '#222222', // Match the exact dark color in reference 49 + alignSelf: 'flex-start', // Allow width to adjust to content 50 + overflow: 'hidden', 51 + minWidth: 180, // Minimum width to look good 52 + maxWidth: 280, // Maximum width to avoid extremely long pills 53 + }, 54 + content: { 55 + flexDirection: 'row', 56 + alignItems: 'center', 57 + paddingVertical: 14, 58 + paddingHorizontal: 14, 59 + height: 60, // Fixed height 60 + }, 61 + image: { 62 + width: 36, 63 + height: 36, 64 + borderRadius: 18, 65 + }, 66 + textContainer: { 67 + marginLeft: 14, 68 + paddingRight: 12, 69 + flex: 1, // Take remaining space 70 + }, 71 + title: { 72 + fontSize: 16, 73 + fontWeight: 'bold', 74 + color: 'white', 75 + }, 76 + artist: { 77 + fontSize: 14, 78 + color: '#AAAAAA', 79 + marginTop: 2, 80 + }, 81 + }); 82 + 83 + export default MusicDisplay;
+122
components/Search/SoundRowsCategory.tsx
··· 1 + import React from 'react'; 2 + import { View, StyleSheet, TouchableOpacity, ScrollView } from 'react-native'; 3 + import { ThemedText } from '@/components/ThemedText'; 4 + import MusicDisplay from './MusicDisplay'; 5 + 6 + // Interface for sound data 7 + export interface SoundProps { 8 + id: string; 9 + title: string; 10 + artist: string; 11 + album: string; 12 + coverImage: string; 13 + plays: number; 14 + } 15 + 16 + interface SoundRowsCategoryProps { 17 + icon: React.ReactNode; 18 + title: string; 19 + sounds: SoundProps[]; 20 + onSoundPress: (sound: SoundProps) => void; 21 + } 22 + 23 + const SoundRowsCategory: React.FC<SoundRowsCategoryProps> = ({ 24 + icon, 25 + title, 26 + sounds, 27 + onSoundPress, 28 + }) => { 29 + const handleViewAll = () => { 30 + console.log(`View all clicked for category: ${title}`); 31 + }; 32 + 33 + // Split sounds array into two roughly equal parts for two rows 34 + const firstRowSounds = sounds.slice(0, Math.ceil(sounds.length / 2)); 35 + const secondRowSounds = sounds.slice(Math.ceil(sounds.length / 2)); 36 + 37 + return ( 38 + <View style={styles.container}> 39 + <View style={styles.headerContainer}> 40 + <View style={styles.titleContainer}> 41 + {icon} 42 + <ThemedText style={styles.title} type="title"> 43 + {title} 44 + </ThemedText> 45 + </View> 46 + <TouchableOpacity onPress={handleViewAll}> 47 + <ThemedText style={styles.viewAll} type="subtitle"> 48 + view all 49 + </ThemedText> 50 + </TouchableOpacity> 51 + </View> 52 + 53 + {/* First row */} 54 + <ScrollView 55 + horizontal 56 + showsHorizontalScrollIndicator={false} 57 + contentContainerStyle={styles.soundsContainer} 58 + decelerationRate="fast" 59 + > 60 + {firstRowSounds.map((sound) => ( 61 + <MusicDisplay 62 + key={sound.id} 63 + sound={sound} 64 + onPress={onSoundPress} 65 + /> 66 + ))} 67 + </ScrollView> 68 + 69 + {/* Second row */} 70 + <ScrollView 71 + horizontal 72 + showsHorizontalScrollIndicator={false} 73 + contentContainerStyle={styles.soundsContainer} 74 + decelerationRate="fast" 75 + > 76 + {secondRowSounds.map((sound) => ( 77 + <MusicDisplay 78 + key={sound.id} 79 + sound={sound} 80 + onPress={onSoundPress} 81 + /> 82 + ))} 83 + </ScrollView> 84 + </View> 85 + ); 86 + }; 87 + 88 + const styles = StyleSheet.create({ 89 + container: { 90 + marginVertical: 20, 91 + width: '100%', 92 + }, 93 + headerContainer: { 94 + flexDirection: 'row', 95 + justifyContent: 'space-between', 96 + alignItems: 'center', 97 + marginBottom: 12, 98 + paddingHorizontal: 16, 99 + }, 100 + titleContainer: { 101 + flexDirection: 'row', 102 + alignItems: 'center', 103 + }, 104 + title: { 105 + fontSize: 20, 106 + fontWeight: 'bold', 107 + marginLeft: 8, 108 + }, 109 + viewAll: { 110 + fontSize: 14, 111 + color: '#666', 112 + }, 113 + soundsContainer: { 114 + paddingLeft: 16, 115 + paddingRight: 16, 116 + paddingVertical: 6, 117 + flexDirection: 'row', 118 + alignItems: 'center', 119 + } 120 + }); 121 + 122 + export default SoundRowsCategory;
+95
components/Search/VideoRowCategory.tsx
··· 1 + import React from 'react'; 2 + import { View, StyleSheet, TouchableOpacity, ScrollView } from 'react-native'; 3 + import { ThemedText } from '@/components/ThemedText'; 4 + import VideoDisplay from '@/components/global/VideoDisplay'; 5 + import { PostProps } from '@/types/Interfaces'; 6 + 7 + interface VideoRowCategoryProps { 8 + icon: React.ReactNode; 9 + title: string; 10 + videos: PostProps[]; 11 + onVideoPress: (post: PostProps) => void; 12 + } 13 + 14 + const VideoRowCategory: React.FC<VideoRowCategoryProps> = ({ 15 + icon, 16 + title, 17 + videos, 18 + onVideoPress, 19 + }) => { 20 + const handleViewAll = () => { 21 + console.log(`View all clicked for category: ${title}`); 22 + }; 23 + 24 + return ( 25 + <View style={styles.container}> 26 + <View style={styles.headerContainer}> 27 + <View style={styles.titleContainer}> 28 + {icon} 29 + <ThemedText style={styles.title} type="title"> 30 + {title} 31 + </ThemedText> 32 + </View> 33 + <TouchableOpacity onPress={handleViewAll}> 34 + <ThemedText style={styles.viewAll} type="subtitle"> 35 + view all 36 + </ThemedText> 37 + </TouchableOpacity> 38 + </View> 39 + 40 + <ScrollView 41 + horizontal 42 + showsHorizontalScrollIndicator={false} 43 + contentContainerStyle={styles.scrollContent} 44 + > 45 + {videos.map((video, index) => ( 46 + <VideoDisplay 47 + key={video.uri || index} 48 + videoSource={video} 49 + onVideoPress={onVideoPress} 50 + containerStyle={styles.videoCard} 51 + /> 52 + ))} 53 + </ScrollView> 54 + </View> 55 + ); 56 + }; 57 + 58 + const styles = StyleSheet.create({ 59 + container: { 60 + marginVertical: 16, 61 + width: '100%', 62 + }, 63 + headerContainer: { 64 + flexDirection: 'row', 65 + justifyContent: 'space-between', 66 + alignItems: 'center', 67 + marginBottom: 12, 68 + paddingHorizontal: 16, 69 + }, 70 + titleContainer: { 71 + flexDirection: 'row', 72 + alignItems: 'center', 73 + }, 74 + title: { 75 + fontSize: 20, 76 + fontWeight: 'bold', 77 + marginLeft: 8, 78 + }, 79 + viewAll: { 80 + fontSize: 14, 81 + color: '#666', 82 + }, 83 + scrollContent: { 84 + paddingLeft: 16, 85 + paddingRight: 8, 86 + }, 87 + videoCard: { 88 + width: 150, 89 + height: 240, 90 + marginRight: 12, 91 + borderRadius: 12, 92 + }, 93 + }); 94 + 95 + export default VideoRowCategory;
+52
components/Search/mockSoundsData.ts
··· 1 + import { SoundProps } from './SoundRowsCategory'; 2 + 3 + export const trendingSounds: SoundProps[] = [ 4 + { 5 + id: '1', 6 + title: 'ANXIETY', 7 + artist: 'Sleepy Hallow', 8 + album: 'Still Sleep?', 9 + coverImage: 'https://i.scdn.co/image/ab6761610000e5eb6b5e2f3811e2f0b1bf52dc7b', 10 + plays: 12000000 11 + }, 12 + { 13 + id: '2', 14 + title: 'Somebody', 15 + artist: 'feat. Kimbra', 16 + album: 'Vows', 17 + coverImage: 'https://i.scdn.co/image/ab6761610000e5eb80d7e99e30d3560cad92dfe6', 18 + plays: 13000000 19 + }, 20 + { 21 + id: '3', 22 + title: 'Good Luck, Babe!', 23 + artist: 'Chappell Roan', 24 + album: 'The Rise and Fall of a Midwest Princess', 25 + coverImage: 'https://i.scdn.co/image/ab6761610000e5eb38dd6eb52dd78e0d5c54f2b1', 26 + plays: 5000000 27 + }, 28 + { 29 + id: '4', 30 + title: 'Dancing Queen', 31 + artist: 'Sleepy Hallow', 32 + album: 'Arrival', 33 + coverImage: 'https://i.scdn.co/image/ab6761610000e5eb6b5e2f3811e2f0b1bf52dc7b', 34 + plays: 8500000 35 + }, 36 + { 37 + id: '5', 38 + title: 'Blinding Lights', 39 + artist: 'The Weeknd', 40 + album: 'After Hours', 41 + coverImage: 'https://f.feridinha.com/zyFjt.png', 42 + plays: 3200000 43 + }, 44 + { 45 + id: '6', 46 + title: 'Cruel Summer', 47 + artist: 'Taylor Swift', 48 + album: 'Lover', 49 + coverImage: 'https://i.scdn.co/image/ab6761610000e5ebf8c586466f28b5f3a26a5743', 50 + plays: 7800000 51 + } 52 + ];
+35 -13
components/Video/VideoBottom.tsx
··· 1 - import React, { useState } from 'react'; 2 - import { View, StyleSheet, TouchableOpacity, useColorScheme } from 'react-native'; 1 + import React, { useState, useRef } from 'react'; 2 + import { View, StyleSheet, TouchableOpacity, useColorScheme, Text } from 'react-native'; 3 3 import { ThemedText } from '../ThemedText'; 4 4 import { Colors } from '@/constants/Colors'; 5 5 import { VideoBottomProps } from '@/types/Interfaces'; ··· 7 7 const VideoBottom: React.FC<VideoBottomProps> = ({ videoData }) => { 8 8 const colorScheme = useColorScheme(); 9 9 const [expanded, setExpanded] = useState(false); 10 + const [textTooLong, setTextTooLong] = useState(false); 10 11 11 12 // Hashtag extractor: supports one or more '#' characters. 12 13 const hashtagExtractor = (text: string) => { ··· 20 21 const cleanedText = textContent.replace(/(#+[a-zA-Z0-9_]+\s*)/g, '').trim(); 21 22 const hashtags = hashtagExtractor(textContent); 22 23 const MAX_HASHTAGS_COLLAPSED = 3; 24 + 25 + const handleTextLayout = (e: any) => { 26 + // Check if the text has been truncated by counting lines 27 + if (e.nativeEvent.lines && e.nativeEvent.lines.length > 2) { 28 + setTextTooLong(true); 29 + } else { 30 + setTextTooLong(false); 31 + } 32 + }; 33 + 34 + const toggleExpand = () => { 35 + setExpanded(!expanded); 36 + }; 23 37 24 38 const styles = StyleSheet.create({ 25 39 container: { ··· 90 104 </ThemedText> 91 105 </TouchableOpacity> 92 106 </ThemedText> 93 - <TouchableOpacity onPress={() => setExpanded(!expanded)}> 107 + <TouchableOpacity onPress={toggleExpand}> 94 108 <ThemedText 95 109 type="description" 96 110 lightColor={Colors.dark.text} 97 111 darkColor={Colors.dark.text} 98 112 style={styles.description} 99 - numberOfLines={!expanded ? 3 : undefined} 113 + numberOfLines={!expanded ? 2 : undefined} 114 + ellipsizeMode="tail" 115 + onTextLayout={handleTextLayout} 100 116 > 101 117 {cleanedText} 102 118 </ThemedText> 103 - {hashtags.length > 0 && ( 119 + 120 + {/* Only show hashtags when expanded or when there's enough space */} 121 + {hashtags.length > 0 && (expanded || !textTooLong) && ( 104 122 <View style={styles.hashtagsContainer}> 105 123 <View style={styles.hashtags}> 106 124 {(expanded || hashtags.length <= MAX_HASHTAGS_COLLAPSED ··· 114 132 </View> 115 133 </View> 116 134 )} 117 - <ThemedText 118 - type="description" 119 - lightColor={Colors.dark.text} 120 - darkColor={Colors.dark.text} 121 - style={styles.readMoreText} 122 - > 123 - {!expanded ? 'Read More' : 'Read Less'} 124 - </ThemedText> 135 + 136 + {/* Only show Read More/Less button when text is actually too long */} 137 + {textTooLong && ( 138 + <ThemedText 139 + type="description" 140 + lightColor={Colors.dark.text} 141 + darkColor={Colors.dark.text} 142 + style={styles.readMoreText} 143 + > 144 + {!expanded ? 'Read More' : 'Read Less'} 145 + </ThemedText> 146 + )} 125 147 </TouchableOpacity> 126 148 </View> 127 149 );
+21 -2
components/global/VideoDisplay.tsx
··· 5 5 StyleSheet, 6 6 View, 7 7 useColorScheme, 8 + StyleProp, 9 + ViewStyle, 8 10 } from 'react-native'; 9 11 import { ThemedText } from '@/components/ThemedText'; 10 12 import { PostProps } from '@/types/Interfaces'; 11 13 import { Ionicons } from '@expo/vector-icons'; 12 14 import { Colors } from '@/constants/Colors'; 15 + import { LinearGradient } from 'expo-linear-gradient'; 13 16 14 17 interface VideoDisplayProps { 15 18 videoSource: PostProps; 16 19 onVideoPress: (post: PostProps) => void; 20 + containerStyle?: StyleProp<ViewStyle>; 17 21 } 18 22 19 23 const VideoDisplay: React.FC<VideoDisplayProps> = ({ 20 24 videoSource, 21 25 onVideoPress, 26 + containerStyle, 22 27 }) => { 23 28 const colorScheme = useColorScheme(); 24 29 ··· 46 51 bottom: 0, 47 52 width: '100%', 48 53 padding: 8, 49 - backgroundColor: 'rgba(0, 0, 0, 0.5)', 54 + zIndex: 2, 50 55 }, 51 56 viewCount: { 52 57 fontSize: 16, ··· 63 68 icon: { 64 69 marginHorizontal: 5, 65 70 }, 71 + gradient: { 72 + position: 'absolute', 73 + bottom: 0, 74 + left: 0, 75 + right: 0, 76 + height: 40, 77 + zIndex: 1, 78 + }, 66 79 }); 67 80 68 81 const thumbnailUri = videoSource.embed?.thumbnail || ''; 69 82 70 83 return ( 71 - <TouchableOpacity style={styles.container} onPress={handleVideoPress}> 84 + <TouchableOpacity style={[styles.container, containerStyle]} onPress={handleVideoPress}> 72 85 {thumbnailUri ? ( 73 86 <Image source={{ uri: thumbnailUri }} style={styles.thumbnail} /> 74 87 ) : null} 88 + 89 + <LinearGradient 90 + colors={['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.7)']} 91 + style={styles.gradient} 92 + /> 93 + 75 94 <View style={styles.overlay}> 76 95 <View style={styles.viewCount}> 77 96 <Ionicons style={styles.icon} name="eye" size={16} color="white" />