A React Native app built with Expo for animated story reading and language learning. Fetches stories from a REST API, displays them with filtering options, and plays YouTube videos with custom start/end times or PDFs.
┌─────────────┐
│ Splash │ (app/index.tsx)
│ Screen │
└──────┬──────┘
│
▼
┌─────────────┐
│ Home │ (app/home.tsx)
│ Screen │
└──────┬──────┘
│
├──► Fetch Stories API
│ POST /Services/GetVideoList
│
├──► Filter by Language/Level
│ POST /Services/GetFilter
│
├──► Search Stories
│ POST /Services/SearchVideo
│
└──► User Selects Story
│
├──► YouTube Video ──► Video Player (app/video.tsx)
│ - Plays with start/end times
│ - Auto-plays next in playlist
│
└──► PDF Story ──────► PDF Viewer (app/pdf.tsx)
- WebView-based renderer
- Framework: React Native 0.81.5 with Expo 54
- Navigation: Expo Router (file-based routing)
- Language: TypeScript 5.9
- State: In-memory playlist store
- Video:
react-native-youtube-iframefor YouTube playback - PDF:
react-native-webviewfor PDF rendering
BookBox/
├── app/ # Expo Router screens (file-based routing)
│ ├── _layout.tsx # Root layout with error boundary & navigation
│ ├── index.tsx # Splash screen
│ ├── home.tsx # Main screen: story list, filters, search
│ ├── video.tsx # YouTube video player with playlist support
│ ├── pdf.tsx # PDF viewer using WebView
│ └── faq.tsx # FAQ/Help screen
├── components/ # Reusable components
│ ├── error-boundary.tsx # Global error handler
│ └── network-status.tsx # Network connectivity indicator
├── store/ # State management
│ └── playlist.ts # In-memory playlist store
├── types/ # TypeScript definitions
│ └── bookbox.ts # API response types & StoryItem interface
├── assets/ # Static assets (images, logos)
├── app.config.js # Expo configuration
└── package.json # Dependencies & scripts
REST API at https://templateapp.planetread.org/Services:
const BASE_URL = 'https://templateapp.planetread.org/Services';
const COMPANY_ID = '37';
// Fetch stories with filters
const fetchStories = async (language: string, level: string, rank: string) => {
const response = await fetchJson<VideoListResponse>(
'/GetVideoList',
{ company_id: COMPANY_ID, Language: language, lavel: level, rank },
abortController.signal
);
return response.Result?.map(mapStory) || [];
};
// Search stories
const searchStories = async (query: string) => {
const response = await fetchJson<SearchResponse>(
'/SearchVideo',
{ company_id: COMPANY_ID, search: query },
abortController.signal
);
return response.Result?.map(mapStory) || [];
};Data Transformation: Maps raw API responses to typed StoryItem:
const mapStory = (item: Record<string, string>): StoryItem => ({
id: item.id ?? '',
youtubeId: item.Youtubeurl ?? '',
videoStart: parseTimeValue(item.Videostart), // "1:30" → 90 seconds
videoEnd: parseTimeValue(item.Videoend),
// ... other fields
});Expo Router Stack navigator with error boundary:
<ErrorBoundary>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="home" />
<Stack.Screen
name="video"
options={{ presentation: 'fullScreenModal' }}
/>
<Stack.Screen name="pdf" />
<Stack.Screen name="faq" />
</Stack>
</ErrorBoundary>Plays YouTube videos with custom start/end times and auto-advances:
const playlist = getPlaylist();
const currentStory = playlist[parseInt(params.index || '0', 10)];
<YoutubePlayer
videoId={currentStory.youtubeId}
initialPlayerParams={{
start: currentStory.videoStart,
end: currentStory.videoEnd,
}}
onStateChange={(state) => {
if (state === 'ended' && hasNext) {
router.replace(`/video?index=${nextIndex}`);
}
}}
/>
// Lock to landscape during playback
useEffect(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
return () => ScreenOrientation.unlockAsync();
}, []);In-memory playlist store:
let playlist: StoryItem[] = [];
export const setPlaylist = (stories: StoryItem[]) => {
playlist = Array.isArray(stories) ? stories : [];
};
export const getPlaylist = (): StoryItem[] => {
return Array.isArray(playlist) ? playlist : [];
};Usage:
// In home.tsx: setPlaylist(filteredStories);
// In video.tsx: const playlist = getPlaylist();TypeScript interfaces for API responses:
export interface StoryItem {
id: string;
youtubeId: string;
videoStart: number; // Start time in seconds
videoEnd: number; // End time in seconds
language: string;
grade: string;
fileUrl: string; // PDF URL if available
}
export interface VideoListResponse {
Success: string;
Result?: Array<Record<string, string>>;
Message?: string;
}- Node.js 18+, npm/yarn, Expo CLI, iOS Simulator (Mac) or Android Emulator
npm install
npm start # or npx expo startnpm run ios # iOS Simulator (or press 'i' in Expo CLI)
npm run android # Android Emulator (or press 'a')
npm run web # Web Browser (or press 'w')npm start # Start Expo dev server
npm run android # Start on Android
npm run ios # Start on iOS
npm run web # Start on web
npm run lint # Run ESLintApp Config (app.config.js):
- Bundle IDs: iOS
com.bookbox.BBXFRE004, Androidcom.bookbox.anibooks - Version: 5.4 (iOS build: 3, Android versionCode: 3)
- Features: New Architecture enabled, React Compiler, Typed routes, Light theme only
Key Dependencies:
{
"expo": "54.0.23",
"react": "19.1.0",
"react-native": "0.81.5",
"expo-router": "~6.0.14",
"react-native-youtube-iframe": "^2.4.1",
"react-native-webview": "13.15.0",
"typescript": "5.9.2"
}Converts time strings to seconds:
parseTimeValue("1:30") // → 90
parseTimeValue("2:15:30") // → 8130
parseTimeValue("45") // → 45API calls use AbortController for timeout (30s):
const controller = new AbortController();
setTimeout(() => controller.abort(), 30000);
const response = await fetch(url, {
method: 'POST',
signal: controller.signal,
});Connectivity indicator using @react-native-community/netinfo:
<NetworkStatus /> // In app/_layout.tsx- Video not playing: Check YouTube video ID and network connectivity
- PDF not loading: Verify
fileUrlis a valid PDF URL - API errors: Check
BASE_URLandCOMPANY_IDinapp/home.tsx - Build errors: Ensure Expo SDK 54 is installed and dependencies are up to date