Success is not final, failure is not fatal: it is the courage to continue that counts, Winston Churchill
You’ve gotta dance like there’s nobody watching, Love like you’ll never be hurt, Sing like there’s nobody listening, And live like it’s heaven on earth, William W. Purkey

When you move a video‑heavy feature from a Python desktop app to a React Native mobile app, you expect to leave certain platform quirks behind. One of those quirks was YouTube’s infamous Error 152 (“Video player is too small” or “The video you have requested is not allowed to be played in embedded players”). In the PySide6 app we had to spin up a local HTTP server just to give the embed page a real origin. With React Native’s WebView, we thought we’d escaped that problem forever — and indeed Error 152 never appears. Instead, we ran straight into Error 153.
This post documents exactly what happened, why the two errors share the same root cause, and how to fix them properly with react-native-youtube-iframe. You’ll come away understanding origins, the YouTube IFrame Player API expectations, and why a native wrapper is the only reliable answer on mobile.
In our desktop app we used PySide6’s QWebEngineView (a Chromium‑based browser widget) to display YouTube embeds. The widget provides a setHtml() method that loads arbitrary HTML directly into the view.
video_view.setHtml('''
<iframe width="560" height="315"
src="https://www.youtube.com/embed/Ac_nVJASVfc?rel=0"
frameborder="0" allowfullscreen></iframe>
''')
The problem is that setHtml() gives the loaded page a null origin. In web security, an origin is a combination of scheme, host, and port. Because the content was injected directly as a string rather than fetched from a real URL, the browser assigns it the opaque origin “null”. YouTube’s embed checks the embedding page’s origin; when it sees “null”, it refuses to serve the video — that’s Error 152. Our workaround was a local HTTP server (VideoServer) that served the page from http://localhost:…, giving it a proper origin that YouTube would accept.
React Native’s WebView works fundamentally differently:
| Python PySide6 | React Native | |
|---|---|---|
| Engine | Qt’s Chromium (QWebEngineView) |
Native iOS WKWebView / Android WebView |
| Origin of embed page | null (setHtml has no origin) |
The URL itself is the origin |
| How we load it | setHtml(html) → null origin |
source={{ uri: embedUrl }} → real URL |
| Error 152? | Yes — null origin rejected | No — URL has a valid origin |
When you write
<WebView
source={{ uri: "https://www.youtube.com/embed/Ac_nVJASVfc?rel=0" }}
/>
the view navigates to that exact URL. The page’s origin becomes https://www.youtube.com. Because the embed page and the embedding page now share the same origin, YouTube’s same‑origin checks pass and Error 152 never occurs. No local HTTP server needed — a huge win.
So we swapped out the Python player for a direct pointing to the embed URL, expecting smooth playback. Instead, we got a new error: Error 153 — “Video player configuration error. Please check your embed settings and try again.”
This error is different from 152 but shares the exact same root cause: the JavaScript environment doesn’t meet YouTube’s expectations. Here’s what happens under the hood:
In other words, while uri loading gives the page a real origin and sidesteps error 152, it does not turn the mobile WebView into a full‑fledged desktop‑class browser. The YouTube IFrame API is designed to be embedded in a parent page that interacts with it via postMessage. In a standalone WebView load, the API still tries to phone home, but the call context looks wrong, so you get error 153.
Both errors stem from the fact that YouTube’s embed infrastructure expects to be loaded inside a real HTML page served from a reachable origin, and it prefers to communicate through the IFrame Player API. Loading the embed URL directly into a mobile WebView shortcuts that architecture:
The correct solution on React Native is not to fight the WebView, but to use a library that acts as that parent page and properly implements the IFrame Player API bridge.
The community‑standard answer is react-native-youtube-iframe. It does three essential things:
Wraps the official IFrame Player API correctly. The library creates a local HTML wrapper page that loads the YouTube embed inside a standard iframe and sets up the required JavaScript message channel.
Handles cookies, callbacks, and player state. It manages player events (onReady, onChangeState, onError, etc.) and exposes them to your React Native code as simple props.
Is maintained for exactly this use case. The library evolves alongside YouTube’s API changes, so you’re not forever patching a custom WebView hack.
By using this wrapper, you effectively give YouTube what it expects: a proper parent document with a real origin that can talk to the IFrame Player API via postMessage. Errors 152 and 153 both disappear.
Stop any running packager, then add the required dependencies:
npx expo install react-native-webview
npx expo install react-native-youtube-iframe
npx expo start --clear
Now you can replace your broken <WebView> with:
import YoutubePlayer from "react-native-youtube-iframe";
<YoutubePlayer
height={300}
play={playing}
videoId={"Ac_nVJASVfc"}
onChangeState={event => console.log(event)}
/>
app/(tabs)/videos.tsx// app/(tabs)/videos.tsx
//
// VIDEOS TAB — Fixed (Chunk 20 error 153 fix)
//
// PROBLEM: Loading youtube.com/embed/ID in a raw WebView gives error 153.
// YouTube's IFrame Player API needs a specific JavaScript environment that
// a plain WebView does not provide out of the box.
//
// SOLUTION: react-native-youtube-iframe — wraps the YouTube IFrame Player
// API correctly, handles all callbacks and configuration internally.
//
// The screen is split into two columns:
// Left column — a list of prayer videos.
// Right column — the video player.
// Imports
import React, { // React — always needed when writing JSX.
// useEffect — runs side‑effects (like fetching data) after the component mounts.
// useState — lets you store values that change over time (videos list, loading state, etc.).
// useCallback — remembers a function so it doesn’t get re‑created on every render, which helps performance inside FlatList.
// useRef — holds a mutable value that doesn’t cause re‑renders.
useEffect, useState, useCallback, useRef,
} from "react";
import {
// View — a container, like a DIV on the web.
// Text — displays text.
// FlatList — a high‑performance list that only renders the items currently visible on screen.
// TouchableHighlight — makes a view tappable and shows a dark overlay while you press.
View, Text, FlatList, TouchableHighlight, TouchableOpacity,
// StyleSheet — creates optimized style objects.
// ActivityIndicator — a spinning loading indicator.
// ListRenderItemInfo — a TypeScript type that describes the object passed to a FlatList’s renderItem function.
StyleSheet, ActivityIndicator, ListRenderItemInfo,
// useWindowDimensions — gives the current width/height of the device window, so we can make the layout responsive.
// Linking — can open external URLs.
useWindowDimensions, Linking,
} from "react-native";
// It provides a React Native component that wraps the YouTube IFrame Player
// and correctly sets up the required JavaScript environment.
import YoutubePlayer from "react-native-youtube-iframe";
// loadVideos — it reads a text file (data/prayer_videos.txt) and returns an array of video objects.
import { loadVideos } from "../../src/services/videoService";
// LoadingSpinner — a reusable loading component we have already created.
import { LoadingSpinner } from "../../src/components/LoadingSpinner";
// EmptyState — a reusable component that shows a message (e.g., when the list is empty or an error occurred).
import { EmptyState } from "../../src/components/EmptyState";
// VideoSite — a TypeScript type that describes what a video object looks like
import type { VideoSite } from "../../src/db/types";
// Colour Constants
/* These are just named colour codes kept in one place.
Instead of writing "#1a1f2e" everywhere, the code uses C.bg, C.panel, etc.
This makes it easy to change the whole colour scheme later. */
const C = {
bg: "#1a1f2e",
panel: "#1e2338",
border: "#2d3550",
accent: "#5b8fd4",
text: "#dde4f0",
muted: "#9ca3af",
faint: "#6b7280",
gold: "#c9a84c",
dim: "#4b5563",
black: "#000000",
};
// ══════════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// This is the screen that Expo Router shows when you visit the “Videos” tab.
// ══════════════════════════════════════════════════════════════════════════════
export default function VideosScreen() {
// State Variables
// videos — the list of all videos we’ll show. Initially an empty array.
const [videos, setVideos] = useState<VideoSite[]>([]);
// loading — true while we’re fetching the video data.
const [loading, setLoading] = useState(true);
// selected — the video that is currently highlighted in the list and will be played. Initially null.
const [selected, setSelected] = useState<VideoSite | null>(null);
// error — if something goes wrong, we store an error message here.
const [error, setError] = useState<string | null>(null);
// Getting the Screen Width
// useWindowDimensions() gives the current width of the phone/tablet screen (portrait or landscape)
const { width: screenWidth } = useWindowDimensions();
// sidebarWidth calculates how wide the left video list should be. It takes the smaller of:
// (i) 200 points, or (ii) 35% of the screen width (rounded down).
// This prevents the list from becoming too wide on tablets.
const sidebarWidth = Math.min(200, Math.floor(screenWidth * 0.35));
// ══════════════════════════════════════════════════════════════════════════════
// Loading Videos when the Screen Mounts
// ══════════════════════════════════════════════════════════════════════════════
useEffect(() => {
// 2. loadVideos() returns a Promise.
loadVideos()
// 3. If it succeeds, ...
.then(list => {
setVideos(list); // ... we put the list into state (videos) and ...
if (list.length > 0) setSelected(list[0]); // ... automatically select the first video
})
// 4. If it fails, we save the error message.
.catch(e => setError(String(e)))
// 5. Whether success or failure, we set loading to false.
.finally(() => setLoading(false));
// 1. deps. The empty array [] means this effect runs only once, when the screen first appears.
}, []);
// ══════════════════════════════════════════════════════════════════════════════
// Conditional Rendering
// ══════════════════════════════════════════════════════════════════════════════
// While loading, the whole screen shows a spinner.
if (loading) return <LoadingSpinner message="Loading videos…" />;
// If an error occurred, we show a warning icon and the error message.
if (error) return <EmptyState icon="⚠️" title="Could not load videos" subtitle={error} />;
// If the file is empty, we show a helpful message telling the user where to add videos.
if (videos.length === 0) {
return (
<EmptyState
icon="🎬"
title="No videos in the list"
subtitle={"Add YouTube IDs to data/prayer_videos.txt\nFormat: VIDEO_ID Title"}
/>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// Main Layout (when everything is ready)
// ══════════════════════════════════════════════════════════════════════════════
return (
// The whole screen uses a root style (s.root).
<View style={s.root}>
// VideoList is the left column. We pass:
<VideoList
videos={videos} // the full list of videos,
selected={selected} // the currently selected video,
sidebarWidth={sidebarWidth} // the computed sidebar width,
onSelect={setSelected} // and a callback that updates selected when the user taps an item.
/>
// A thin divider separates the two columns.
<View style={s.divider} />
// VideoPlayer is the right column. It receives the selected video.
<VideoPlayer selected={selected} />
</View>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// LEFT COLUMN — VIDEO LIST
// ══════════════════════════════════════════════════════════════════════════════
/* This is the left sidebar of our videos.tsx screen. A clean, efficient, scrollable sidebar of videos. It shows:
(i) A header: “Prayer Videos” + count.
(ii) A scrollable list of videos (each one is a VideoSite).
(iii) A footer telling the user where to edit the text file.
It is pure UI — no business logic. It receives everything it needs through props.
*/
const ITEM_HEIGHT = 60;
function VideoList({
/* What each prop means:
videos → array of all available prayer videos; selected → the currently selected video (or null)
sidebarWidth → dynamic width based on screen size; onSelect(video) → callback to notify parent when user taps a video. */
// This component does not manage state. It simply renders what it is told.
videos, selected, sidebarWidth, onSelect,
}: {
videos: VideoSite[];
selected: VideoSite | null;
sidebarWidth: number;
onSelect: (v: VideoSite) => void;
}) {
// FlatList 😊 calls this function for every video
// 0. Why useCallback?
// Because FlatList re-renders items only when the render function changes.
const renderItem = useCallback(
// 2. What the function does:
({ item }: ListRenderItemInfo<VideoSite>) => (
// For each video:
// A. It renders a VideoListItem
<VideoListItem
video={item}
// B. It highlights it if it is the selected one
isSelected={item.id === selected?.id}
// C. It calls onSelect(item) when tapped, updating the selected video in the parent.
onPress={() => onSelect(item)}
/>
),
// 1. Dependencies:
// selected?.id → re-render items only when the selected video changes
// onSelect → if the callback changes (very rare indeed), update the function
[selected?.id, onSelect]
);
// The returned layout. Sidebar Layout.
return (
// Outer container: s.sidebar with a dynamic width.
<View style={[s.sidebar, { width: sidebarWidth }]}>
// Header: displays the title “Prayer Videos” and the count of videos.
<View style={s.sidebarHeader}>
<Text style={s.sidebarLabel}>Prayer Videos</Text>
<Text style={s.sidebarCount}>{videos.length}</Text>
</View>
// FlatList: 😊
<FlatList
data={videos} // data is the full array.
keyExtractor={item => item.id} // keyExtractor gives each item a unique key (its YouTube ID).
renderItem={renderItem} // renderItem uses our callback (our memoized function)
// ItemSeparatorComponent inserts a small dividing line between rows.
ItemSeparatorComponent={() => <View style={s.separator} />}
// showsVerticalScrollIndicator={false} hides the scrollbar for a cleaner look.
showsVerticalScrollIndicator={false}
/>
// Footer: a small hint telling the user how to add more videos.
<View style={s.sidebarFooter}>
<Text style={s.sidebarFooterText}>
Edit data/prayer_videos.txt{"\n"}to add more videos
</Text>
</View>
</View>
);
}
// Each Row — VideoListItem
function VideoListItem({
video, isSelected, onPress,
}: {
video: VideoSite;
isSelected: boolean;
onPress: () => void;
}) {
return (
/* TouchableHighlight wraps the whole row.
When pressed, it briefly shows a dark blue overlay (underlayColor="#252d45").
The height is fixed to ITEM_HEIGHT (60 points). */
<TouchableHighlight underlayColor="#252d45" onPress={onPress} style={{ height: ITEM_HEIGHT }}>
// Inside, a View has the base style s.listItem, and if this row is selected, it also gets s.listItemSelected.
<View style={[s.listItem, isSelected && s.listItemSelected]}>
// If selected, a thin coloured bar (s.listItemBorder) appears on the left edge to give a visual indicator.
{isSelected && <View style={s.listItemBorder} />}
// The play icon shows a filled triangle (▶) when selected,
// and an empty circle (○) otherwise. The style changes colour accordingly.
<Text style={[s.playIcon, isSelected && s.playIconSelected]}>
{isSelected ? "▶" : "○"}
</Text>
// The title text can wrap up to 3 lines (numberOfLines={3}) and changes style when selected.
<Text style={[s.listItemTitle, isSelected && s.listItemTitleSelected]} numberOfLines={3}>
{video.title}
</Text>
</View>
</TouchableHighlight>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// RIGHT COLUMN — VIDEO PLAYER
// ══════════════════════════════════════════════════════════════════════════════
/* It shows a YouTube video for a selected prayer, allows you to play/pause it,
and gives you a button to open the video directly in YouTube.
It uses a package called react-native-youtube-iframe
which handles the tricky parts of embedding YouTube inside a React Native app.
“Error 153” is a common YouTube embed error that occurs when the API isn’t loaded or configured properly.
The library handles that for you.
*/
// The component is called VideoPlayer, and it receives one prop (input):
// selected: either a VideoSite object (which contains id and title) or null.
// When selected is null, no video is selected yet → the player shows a friendly empty state.
// When a video is selected, it shows the YouTube player, a title, and controls.
function VideoPlayer({ selected }: { selected: VideoSite | null }) {
// State variables
/* playing (boolean): whether the video is currently playing (true) or paused (false). It starts as false.
ready (boolean): whether the YouTube player has finished loading and is ready to accept commands. It starts as false.
These are state hooks. When you change them with setPlaying or setReady, the component re‑renders. */
const [playing, setPlaying] = useState(false);
const [ready, setReady] = useState(false);
// useRef creates a persistent reference that survives re‑renders. It’s like a “box” you can put something in.
// The library requires a ref so you can later call methods on the YouTube player (e.g., playerRef.current?.seekTo(10)).
// The type any means we don’t define a strict TypeScript type for the ref; we trust the library.
const playerRef = useRef<any>(null);
// Reset state when video changes
// 1. useEffect runs a function when something changes...
useEffect(() => {
/* 2. The effect resets playing to false (stop playback)
and ready to false (hide custom controls and show loading indicator),
because a new video needs to load fresh. */
setPlaying(false);
setReady(false);
// ... The dependency array [selected?.id] means: whenever the selected video’s id changes, run this effect.
// selected?.id uses optional chaining; if selected is null, it’s undefined,
// so the effect also runs when deselecting a video (going from a video to null).
}, [selected?.id]);
// No video selected → empty state
if (!selected) {
// If selected is null or undefined, the component returns early.
return (
/* It renders a container (View with the same styling as the player area, s.playerContainer) with an EmptyState component. */
<View style={s.playerContainer}>
/* EmptyState is a reusable piece that shows an icon, title, and subtitle.
This tells the user to pick a video from the list (left column). */
<EmptyState icon="🙏" title="Select a prayer video" subtitle="Tap a video in the list" />
</View>
);
}
// Normal view (video selected)
return (
// Once we know a video is selected, the component returns a layout with several sections:
// a. Container. The whole right panel sits inside this View.
<View style={s.playerContainer}>
{/* b. Title bar */}
<View style={s.playerHeader}>
// A row showing the video title ...
// numberOfLines={1} trims the title if it’s too long, showing “…” at the end.
<Text style={s.playerTitle} numberOfLines={1}>{selected.title}</Text>
// ... and a button.
/* It uses TouchableOpacity (a pressable wrapper that fades when touched).
When pressed, it calls Linking.openURL, which opens the video in the YouTube app or browser,
using the video ID (selected.id). */
<TouchableOpacity
style={s.openBtn}
onPress={() => Linking.openURL(`https://www.youtube.com/watch?v=${selected.id}`)}
>
<Text style={s.openBtnText}>↗ YouTube</Text>
</TouchableOpacity>
</View>
{/* c. YouTube player. This is the heart of the component. Let’s unpack each prop: */}
{/*
YoutubePlayer from react-native-youtube-iframe:
videoId — the 11-character YouTube ID
height — player height in density-independent pixels
play — controlled playback state (true = playing)
onReady — called when the player has loaded and is ready
onChangeState — called when playback state changes:
"unstarted" | "ended" | "playing" | "paused" | "buffering"
*/}
<YoutubePlayer
ref={playerRef} // – connects the ref we created earlier.
// – the 11‑character YouTube video ID (from the selected object). This tells the player which video to load.
videoId={selected.id}
height={220} // – player height in density‑independent pixels (dp).
// Controlled playback: when playing is true, the video plays; when false, it pauses. This value comes from our state.
play={playing}
// A callback that fires when the player has loaded the video and is ready to interact.
// We set ready to true, which reveals our custom play/pause button (because the player is no longer in a loading state).
onReady={() => setReady(true)}
// It is called whenever the playback state changes.
// The state string can be "unstarted", "ended", "playing", "paused", "buffering".
// We only handle "ended" by setting playing to false (so our custom button will show “▶ Play” again).
onChangeState={(state: string) => {
if (state === "ended") setPlaying(false);
}}
// An object of options passed to YouTube when the player is created:
initialPlayerParams={{
// rel=0: do not show related videos at the end (only the chosen video).
rel: false,
// modestbranding: reduce the YouTube logo and branding.
modestbranding: true,
// controls: show YouTube’s own player controls (play/pause, progress bar, volume, etc.).
controls: true,
// preventFullScreen: false — allow the user to go full‑screen.
preventFullScreen: false,
}}
// Custom style for the WebView that wraps the YouTube IFrame (e.g., to give it rounded corners, remove scrollbars, etc.).
webViewStyle={s.youtubeWebView}
// Additional props passed directly to the underlying React Native WebView:
webViewProps={{
// Allows the WebView to load resources from any origin; needed for YouTube’s cross‑origin requests.
originWhitelist: ["*"],
// Enables hardware acceleration on Android for smoother video playback (a common fix).
androidHardwareAccelerationDisabled: false,
}}
/>
{/* Loading indicator shown until player is ready */}
// This is shown only while ready is false (i.e., before the player fires onReady).
{!ready && (
// It displays a small spinning indicator (ActivityIndicator) and the text “Preparing player…”.
<View style={s.loadingRow}>
<ActivityIndicator size="small" color={C.gold} />
<Text style={s.loadingText}>Preparing player…</Text>
</View>
)}
{/* Custom Play / Pause button below the player */}
// This block only renders after the player is ready (ready === true).
{ready && (
<View style={s.controls}>
// The TouchableOpacity toggles playing state using the functional update setPlaying(p => !p).
// This flips the value safely based on the previous state.
<TouchableOpacity
style={s.playBtn}
onPress={() => setPlaying(p => !p)}
>
// The button text changes depending on whether the video is currently playing:
// Playing → “⏸ Pause”. Otherwise, Paused → “▶ Play”
<Text style={s.playBtnText}>
{playing ? "⏸ Pause" : "▶ Play"}
</Text>
</TouchableOpacity>
// Below the button is a hint telling the user that YouTube’s own controls are also available directly on the player.
<Text style={s.controlsHint}>
You can also use the player controls above
</Text>
</View>
)}
{/* Spacer */}
// An empty View with flex: 1 takes all remaining vertical space,
// pushing everything above it up and stretching to fill the container.
<View style={{ flex: 1, backgroundColor: C.bg }} />
</View>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// STYLES
// ══════════════════════════════════════════════════════════════════════════════
const s = StyleSheet.create({
root: { flex: 1, flexDirection: "row", backgroundColor: C.bg },
divider: { width: 1, backgroundColor: C.border },
// Sidebar
sidebar: { backgroundColor: C.panel },
sidebarHeader: { flexDirection: "row", justifyContent: "space-between",
alignItems: "center", paddingHorizontal: 12,
paddingVertical: 10, borderBottomWidth: 1,
borderBottomColor: C.border },
sidebarLabel: { fontSize: 10, fontWeight: "700", color: C.faint,
textTransform: "uppercase", letterSpacing: 0.8 },
sidebarCount: { fontSize: 10, color: C.accent, backgroundColor: "#1a2a3a",
paddingHorizontal: 5, paddingVertical: 1,
borderRadius: 8, overflow: "hidden" },
separator: { height: 1, backgroundColor: "#1e2338", marginLeft: 40 },
sidebarFooter: { padding: 12, borderTopWidth: 1, borderTopColor: C.border },
sidebarFooterText: { fontSize: 9, color: C.dim, textAlign: "center",
lineHeight: 14, fontFamily: "monospace" },
// List item
listItem: { flexDirection: "row", alignItems: "center",
height: ITEM_HEIGHT, paddingLeft: 12, paddingRight: 8, gap: 8 },
listItemSelected: { backgroundColor: "#222840" },
listItemBorder: { position: "absolute", left: 0, top: 0, bottom: 0,
width: 2, backgroundColor: C.accent },
playIcon: { fontSize: 12, color: C.faint, width: 16,
textAlign: "center", flexShrink: 0 },
playIconSelected: { color: C.accent },
listItemTitle: { flex: 1, fontSize: 12, color: C.muted, lineHeight: 17 },
listItemTitleSelected: { color: C.text, fontWeight: "600" },
// Player
playerContainer: { flex: 1, backgroundColor: C.bg },
playerHeader: { flexDirection: "row", alignItems: "center",
paddingHorizontal: 14, paddingVertical: 10,
backgroundColor: C.panel, borderBottomWidth: 1,
borderBottomColor: C.border, gap: 10 },
playerTitle: { flex: 1, fontSize: 13, fontWeight: "600", color: C.text },
openBtn: { backgroundColor: C.panel, borderRadius: 6,
paddingHorizontal: 10, paddingVertical: 5,
borderWidth: 1, borderColor: C.border, flexShrink: 0 },
openBtnText: { fontSize: 11, color: C.accent, fontWeight: "600" },
youtubeWebView: { backgroundColor: C.black },
loadingRow: { flexDirection: "row", alignItems: "center", gap: 10,
padding: 12 },
loadingText: { color: C.muted, fontSize: 13 },
controls: { padding: 16, gap: 10 },
playBtn: { backgroundColor: C.accent, borderRadius: 8,
paddingVertical: 10, alignItems: "center" },
playBtnText: { color: "#fff", fontSize: 14, fontWeight: "700" },
controlsHint: { fontSize: 11, color: C.dim, textAlign: "center" },
});
npx expo start --clear