feat(native): add English and Spanish i18n support

This commit is contained in:
2026-02-13 19:04:30 +01:00
parent 79b29c3959
commit c3cc78794f
7 changed files with 282 additions and 46 deletions

View File

@@ -4,6 +4,7 @@ import { ClerkProvider, SignedIn, SignedOut, useAuth } from "@clerk/clerk-expo";
import { tokenCache } from "@clerk/clerk-expo/token-cache";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { I18nProvider, useI18n } from "./src/i18n/I18nProvider";
import { useAppTheme } from "./src/lib/theme";
import { SignInScreen } from "./src/screens/SignInScreen";
import { StreamerScreen } from "./src/screens/StreamerScreen";
@@ -35,13 +36,22 @@ function AuthReadyGate() {
}
export default function App() {
return (
<I18nProvider>
<AppContent />
</I18nProvider>
);
}
function AppContent() {
const theme = useAppTheme();
const { t } = useI18n();
if (!publishableKey) {
return (
<View style={[styles.loadingWrap, { backgroundColor: theme.background }]}>
<Text style={[styles.errorText, { color: theme.destructive }]}>
Missing EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY
{t("missingClerkKey")}
</Text>
</View>
);

View File

@@ -8,6 +8,7 @@ Simple React Native streamer app that:
- Captures Android screen with `getDisplayMedia()`
- Hosts a room on `/ws/signaling` and streams to connected viewers
- Uses matching light/dark palette semantics from the Helium web app
- Includes built-in i18n for English and Spanish based on device locale
## Auth implementation notes (from Clerk docs via Context7)

View File

@@ -8,6 +8,7 @@ import {
} from "react-native-webrtc";
import { getSignalingUrl } from "../lib/signaling";
import type { MessageKey } from "../i18n/messages";
import type {
IncomingSignalingMessage,
NativeIceServer,
@@ -23,7 +24,8 @@ interface PeerConnectionHandlers {
}
interface UseHeliumStreamerResult {
status: string;
statusKey: MessageKey;
statusParams?: Record<string, string | number>;
roomCode: string;
streamUrl: string | null;
viewerCount: number;
@@ -78,7 +80,10 @@ export function useHeliumStreamer(
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const peersRef = useRef<Record<string, RTCPeerConnection>>({});
const [status, setStatus] = useState<string>("idle");
const [statusKey, setStatusKey] = useState<MessageKey>("statusIdle");
const [statusParams, setStatusParams] = useState<
Record<string, string | number> | undefined
>(undefined);
const [roomCode, setRoomCode] = useState<string>("");
const [streamUrl, setStreamUrl] = useState<string | null>(null);
const [viewerCount, setViewerCount] = useState<number>(0);
@@ -126,7 +131,8 @@ export function useHeliumStreamer(
setRoomCode("");
setStreamUrl(null);
setIsSharing(false);
setStatus("stopped");
setStatusKey("statusStopped");
setStatusParams(undefined);
}, [closeAllPeers]);
const handleViewerJoined = useCallback(
@@ -169,7 +175,8 @@ export function useHeliumStreamer(
};
peerWithHandlers.onconnectionstatechange = (): void => {
setStatus(`viewer ${viewerId}: ${peer.connectionState}`);
setStatusKey("statusPeerState");
setStatusParams({ state: peer.connectionState });
};
const offer = (await peer.createOffer()) as NativeSessionDescriptionInit;
@@ -191,12 +198,14 @@ export function useHeliumStreamer(
if (message.event === "room-created") {
setRoomCode(message.roomId);
setStatus(`room code: ${message.roomId}`);
setStatusKey("statusRoomCreated");
setStatusParams({ roomId: message.roomId });
return;
}
if (message.event === "viewer-joined") {
setStatus(`viewer joined: ${message.viewerId}`);
setStatusKey("statusViewerJoined");
setStatusParams(undefined);
await handleViewerJoined(message.viewerId);
return;
}
@@ -232,7 +241,8 @@ export function useHeliumStreamer(
}
if (message.event === "error") {
setStatus(`error: ${message.message}`);
setStatusKey("statusError");
setStatusParams({ message: message.message });
}
},
[handleViewerJoined],
@@ -242,11 +252,13 @@ export function useHeliumStreamer(
stopSharing();
if (!iceServers.length) {
setStatus("no preset selected");
setStatusKey("statusNoPreset");
setStatusParams(undefined);
return;
}
setStatus("requesting screen capture");
setStatusKey("statusRequestingCapture");
setStatusParams(undefined);
const stream = await (mediaDevices as unknown as {
getDisplayMedia: (constraints?: {
video?: boolean;
@@ -264,12 +276,14 @@ export function useHeliumStreamer(
const audioTrackCount = stream.getAudioTracks().length;
if (!videoTrackCount) {
setStatus("screen capture started without video track");
setStatusKey("statusNoVideoTrack");
setStatusParams(undefined);
stopSharing();
return;
}
setStatus(`capturing ${videoTrackCount} video / ${audioTrackCount} audio tracks`);
setStatusKey("statusCapturing");
setStatusParams({ video: videoTrackCount, audio: audioTrackCount });
stream.getTracks().forEach((track) => {
const streamTrack = track as unknown as MediaStreamTrack & {
@@ -280,13 +294,15 @@ export function useHeliumStreamer(
};
});
setStatus("connecting signaling");
setStatusKey("statusConnectingSignaling");
setStatusParams(undefined);
const ws = new WebSocket(getSignalingUrl());
wsRef.current = ws;
ws.onopen = (): void => {
setStatus("creating room");
setStatusKey("statusCreatingRoom");
setStatusParams(undefined);
sendMessage({ event: "create-room" });
heartbeatRef.current = setInterval(() => {
@@ -299,7 +315,8 @@ export function useHeliumStreamer(
};
ws.onerror = (): void => {
setStatus("websocket error");
setStatusKey("statusWebsocketError");
setStatusParams(undefined);
};
ws.onclose = (): void => {
@@ -308,7 +325,8 @@ export function useHeliumStreamer(
heartbeatRef.current = null;
}
if (isSharing) {
setStatus("websocket closed");
setStatusKey("statusWebsocketClosed");
setStatusParams(undefined);
}
};
}, [handleIncomingMessage, iceServers, isSharing, sendMessage, stopSharing]);
@@ -320,7 +338,8 @@ export function useHeliumStreamer(
}, [stopSharing]);
return {
status,
statusKey,
statusParams,
roomCode,
streamUrl,
viewerCount,

View File

@@ -0,0 +1,54 @@
import { createContext, useContext, useMemo } from "react";
import { messages, type Locale, type MessageKey } from "./messages";
interface I18nContextValue {
locale: Locale;
t: (key: MessageKey, params?: Record<string, string | number>) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
function resolveLocale(): Locale {
const locale = Intl.DateTimeFormat().resolvedOptions().locale.toLowerCase();
return locale.startsWith("es") ? "es" : "en";
}
function translate(
locale: Locale,
key: MessageKey,
params?: Record<string, string | number>,
): string {
const template = messages[locale][key] ?? messages.en[key] ?? key;
if (!params) {
return template;
}
return Object.entries(params).reduce((result, [paramKey, value]) => {
return result.replaceAll(`{${paramKey}}`, String(value));
}, template);
}
export function I18nProvider({ children }: { children: React.ReactNode }) {
const locale = resolveLocale();
const value = useMemo<I18nContextValue>(() => {
return {
locale,
t: (key, params) => translate(locale, key, params),
};
}, [locale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const context = useContext(I18nContext);
if (!context) {
throw new Error("useI18n must be used within I18nProvider");
}
return context;
}

View File

@@ -0,0 +1,138 @@
export type Locale = "en" | "es";
export type MessageKey =
| "missingClerkKey"
| "appTitle"
| "signInSubtitle"
| "email"
| "password"
| "signIn"
| "signingIn"
| "signedIn"
| "signInFailed"
| "needsExtraStep"
| "loadingPresets"
| "couldNotReadToken"
| "noPresetsFound"
| "loadedIceServers"
| "failedToLoadPresets"
| "failedToParsePreset"
| "streamerTitle"
| "streamerSubtitle"
| "preset"
| "session"
| "status"
| "viewers"
| "defaultLabel"
| "startShare"
| "stop"
| "signOut"
| "previewActive"
| "previewIdle"
| "statusIdle"
| "statusStopped"
| "statusNoPreset"
| "statusRequestingCapture"
| "statusNoVideoTrack"
| "statusCapturing"
| "statusConnectingSignaling"
| "statusCreatingRoom"
| "statusRoomCreated"
| "statusViewerJoined"
| "statusPeerState"
| "statusWebsocketError"
| "statusWebsocketClosed"
| "statusError";
type MessageMap = Record<MessageKey, string>;
export const messages: Record<Locale, MessageMap> = {
en: {
missingClerkKey: "Missing EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY",
appTitle: "Helium Native",
signInSubtitle: "Sign in with Clerk",
email: "Email",
password: "Password",
signIn: "Sign in",
signingIn: "Signing in...",
signedIn: "Signed in",
signInFailed: "Sign-in failed",
needsExtraStep: "Needs extra step: {status}",
loadingPresets: "Loading presets",
couldNotReadToken: "Could not read auth token",
noPresetsFound: "No presets found",
loadedIceServers: "Loaded {count} ICE server entries",
failedToLoadPresets: "Failed to load presets: {message}",
failedToParsePreset: "Failed to parse ICE servers from preset",
streamerTitle: "Helium Streamer",
streamerSubtitle: "Share your Android screen to Helium viewers",
preset: "Preset",
session: "Session",
status: "Status",
viewers: "Viewers",
defaultLabel: "default",
startShare: "Start screen share",
stop: "Stop",
signOut: "Sign out",
previewActive: "Screen capture active. Preview disabled to reduce latency.",
previewIdle: "Start sharing to broadcast this phone screen",
statusIdle: "Idle",
statusStopped: "Stopped",
statusNoPreset: "No preset selected",
statusRequestingCapture: "Requesting screen capture",
statusNoVideoTrack: "Screen capture started without video track",
statusCapturing: "Capturing {video} video / {audio} audio tracks",
statusConnectingSignaling: "Connecting signaling",
statusCreatingRoom: "Creating room",
statusRoomCreated: "Room code: {roomId}",
statusViewerJoined: "Viewer joined",
statusPeerState: "Peer state: {state}",
statusWebsocketError: "WebSocket error",
statusWebsocketClosed: "WebSocket closed",
statusError: "Error: {message}",
},
es: {
missingClerkKey: "Falta EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY",
appTitle: "Helium Nativo",
signInSubtitle: "Inicia sesion con Clerk",
email: "Correo",
password: "Contrasena",
signIn: "Iniciar sesion",
signingIn: "Iniciando sesion...",
signedIn: "Sesion iniciada",
signInFailed: "Error al iniciar sesion",
needsExtraStep: "Falta un paso adicional: {status}",
loadingPresets: "Cargando presets",
couldNotReadToken: "No se pudo leer el token",
noPresetsFound: "No se encontraron presets",
loadedIceServers: "Se cargaron {count} entradas ICE",
failedToLoadPresets: "Error al cargar presets: {message}",
failedToParsePreset: "Error al parsear ICE del preset",
streamerTitle: "Helium Emisor",
streamerSubtitle: "Comparte la pantalla de Android con Helium",
preset: "Preset",
session: "Sesion",
status: "Estado",
viewers: "Espectadores",
defaultLabel: "predeterminado",
startShare: "Iniciar pantalla",
stop: "Detener",
signOut: "Cerrar sesion",
previewActive: "Captura activa. Vista previa desactivada para menor latencia.",
previewIdle: "Inicia la captura para transmitir esta pantalla",
statusIdle: "En espera",
statusStopped: "Detenido",
statusNoPreset: "No hay preset seleccionado",
statusRequestingCapture: "Solicitando captura de pantalla",
statusNoVideoTrack: "La captura inicio sin pista de video",
statusCapturing: "Capturando {video} video / {audio} audio",
statusConnectingSignaling: "Conectando senalizacion",
statusCreatingRoom: "Creando sala",
statusRoomCreated: "Codigo de sala: {roomId}",
statusViewerJoined: "Se unio un espectador",
statusPeerState: "Estado del peer: {state}",
statusWebsocketError: "Error de WebSocket",
statusWebsocketClosed: "WebSocket cerrado",
statusError: "Error: {message}",
},
};

View File

@@ -10,9 +10,11 @@ import {
} from "react-native";
import { useAppTheme } from "../lib/theme";
import { useI18n } from "../i18n/I18nProvider";
export function SignInScreen() {
const theme = useAppTheme();
const { t } = useI18n();
const { isLoaded, signIn, setActive } = useSignIn();
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
@@ -24,7 +26,7 @@ export function SignInScreen() {
return;
}
setStatus("Signing in...");
setStatus(t("signingIn"));
try {
const attempt = await signIn.create({
@@ -34,33 +36,33 @@ export function SignInScreen() {
if (attempt.status === "complete") {
await setActive({ session: attempt.createdSessionId });
setStatus("Signed in");
setStatus(t("signedIn"));
return;
}
setStatus(`Needs extra step: ${attempt.status}`);
setStatus(t("needsExtraStep", { status: String(attempt.status) }));
} catch {
setStatus("Sign-in failed");
setStatus(t("signInFailed"));
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>Helium Native</Text>
<Text style={styles.subtitle}>Sign in with Clerk</Text>
<Text style={styles.title}>{t("appTitle")}</Text>
<Text style={styles.subtitle}>{t("signInSubtitle")}</Text>
<TextInput
autoCapitalize="none"
keyboardType="email-address"
onChangeText={setEmail}
placeholder="Email"
placeholder={t("email")}
placeholderTextColor="#7e8794"
style={styles.input}
value={email}
/>
<TextInput
onChangeText={setPassword}
placeholder="Password"
placeholder={t("password")}
placeholderTextColor="#7e8794"
secureTextEntry
style={styles.input}
@@ -75,7 +77,7 @@ export function SignInScreen() {
style={styles.button}
>
{isLoaded ? (
<Text style={styles.buttonText}>Sign in</Text>
<Text style={styles.buttonText}>{t("signIn")}</Text>
) : (
<ActivityIndicator color={theme.primaryForeground} />
)}

View File

@@ -10,6 +10,8 @@ import {
} from "react-native";
import { useHeliumStreamer } from "../hooks/useHeliumStreamer";
import { useI18n } from "../i18n/I18nProvider";
import type { MessageKey } from "../i18n/messages";
import { useAppTheme } from "../lib/theme";
import { getPresets } from "../lib/presets";
import type { NativeIceServer, PresetUser } from "../types/presets";
@@ -17,16 +19,21 @@ import type { NativeIceServer, PresetUser } from "../types/presets";
export function StreamerScreen() {
const { getToken, signOut } = useAuth();
const theme = useAppTheme();
const { t } = useI18n();
const [presets, setPresets] = useState<PresetUser[]>([]);
const [presetId, setPresetId] = useState<string>("");
const [iceServers, setIceServers] = useState<NativeIceServer[]>([]);
const [presetStatus, setPresetStatus] = useState<string>("loading presets");
const [presetStatusKey, setPresetStatusKey] = useState<MessageKey>("loadingPresets");
const [presetStatusParams, setPresetStatusParams] = useState<
Record<string, string | number> | undefined
>(undefined);
const styles = useMemo(() => createStyles(theme), [theme]);
const {
status,
statusKey,
statusParams,
roomCode,
viewerCount,
isSharing,
@@ -43,7 +50,8 @@ export function StreamerScreen() {
const token = await getToken();
if (!token) {
setPresetStatus("could not read auth token");
setPresetStatusKey("couldNotReadToken");
setPresetStatusParams(undefined);
return;
}
@@ -52,7 +60,8 @@ export function StreamerScreen() {
setPresets(availablePresets);
if (!availablePresets.length) {
setPresetStatus("no presets found");
setPresetStatusKey("noPresetsFound");
setPresetStatusParams(undefined);
return;
}
@@ -61,7 +70,8 @@ export function StreamerScreen() {
setPresetId(defaultPreset.presetId);
} catch (error) {
setPresetStatus(`failed to load presets: ${(error as Error).message}`);
setPresetStatusKey("failedToLoadPresets");
setPresetStatusParams({ message: (error as Error).message });
}
};
@@ -82,22 +92,24 @@ export function StreamerScreen() {
: rawIceServers;
setIceServers(parsedIceServers ?? []);
setPresetStatus(`loaded ${(parsedIceServers ?? []).length} ICE server entries`);
setPresetStatusKey("loadedIceServers");
setPresetStatusParams({ count: (parsedIceServers ?? []).length });
} catch {
setIceServers([]);
setPresetStatus("failed to parse ICE servers from preset");
setPresetStatusKey("failedToParsePreset");
setPresetStatusParams(undefined);
}
}, [selectedPreset]);
return (
<SafeAreaView style={styles.safeArea}>
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>Helium Streamer</Text>
<Text style={styles.subtitle}>Share your Android screen to Helium viewers</Text>
<Text style={styles.title}>{t("streamerTitle")}</Text>
<Text style={styles.subtitle}>{t("streamerSubtitle")}</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>Preset</Text>
<Text style={styles.small}>{presetStatus}</Text>
<Text style={styles.cardTitle}>{t("preset")}</Text>
<Text style={styles.small}>{t(presetStatusKey, presetStatusParams)}</Text>
<View style={styles.presetList}>
{presets.map((preset) => {
@@ -117,7 +129,7 @@ export function StreamerScreen() {
]}
>
{preset.preset.name}
{preset.isDefault ? " (default)" : ""}
{preset.isDefault ? ` (${t("defaultLabel")})` : ""}
</Text>
</Pressable>
);
@@ -126,9 +138,9 @@ export function StreamerScreen() {
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>Session</Text>
<Text style={styles.small}>Status: {status}</Text>
<Text style={styles.small}>Viewers: {viewerCount}</Text>
<Text style={styles.cardTitle}>{t("session")}</Text>
<Text style={styles.small}>{t("status")}: {t(statusKey, statusParams)}</Text>
<Text style={styles.small}>{t("viewers")}: {viewerCount}</Text>
<Text style={styles.roomCode}>{roomCode || "------"}</Text>
<View style={styles.actions}>
@@ -138,11 +150,11 @@ export function StreamerScreen() {
}}
style={styles.primaryButton}
>
<Text style={styles.primaryButtonText}>Start screen share</Text>
<Text style={styles.primaryButtonText}>{t("startShare")}</Text>
</Pressable>
<Pressable onPress={stopSharing} style={styles.secondaryButton}>
<Text style={styles.secondaryButtonText}>Stop</Text>
<Text style={styles.secondaryButtonText}>{t("stop")}</Text>
</Pressable>
</View>
</View>
@@ -150,10 +162,10 @@ export function StreamerScreen() {
<View style={styles.preview}>
{isSharing ? (
<Text style={styles.previewPlaceholder}>
Screen capture active. Preview disabled to reduce latency.
{t("previewActive")}
</Text>
) : (
<Text style={styles.previewPlaceholder}>Start sharing to broadcast this phone screen</Text>
<Text style={styles.previewPlaceholder}>{t("previewIdle")}</Text>
)}
</View>
@@ -163,7 +175,7 @@ export function StreamerScreen() {
}}
style={styles.signOutButton}
>
<Text style={styles.signOutText}>Sign out</Text>
<Text style={styles.signOutText}>{t("signOut")}</Text>
</Pressable>
</ScrollView>
</SafeAreaView>