diff --git a/native-app/App.tsx b/native-app/App.tsx
index 1ed6fe1..fdd26dc 100644
--- a/native-app/App.tsx
+++ b/native-app/App.tsx
@@ -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 (
+
+
+
+ );
+}
+
+function AppContent() {
const theme = useAppTheme();
+ const { t } = useI18n();
if (!publishableKey) {
return (
- Missing EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY
+ {t("missingClerkKey")}
);
diff --git a/native-app/README.md b/native-app/README.md
index 6435feb..d130961 100644
--- a/native-app/README.md
+++ b/native-app/README.md
@@ -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)
diff --git a/native-app/src/hooks/useHeliumStreamer.ts b/native-app/src/hooks/useHeliumStreamer.ts
index 9039fbb..8b77294 100644
--- a/native-app/src/hooks/useHeliumStreamer.ts
+++ b/native-app/src/hooks/useHeliumStreamer.ts
@@ -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;
roomCode: string;
streamUrl: string | null;
viewerCount: number;
@@ -78,7 +80,10 @@ export function useHeliumStreamer(
const heartbeatRef = useRef | null>(null);
const peersRef = useRef>({});
- const [status, setStatus] = useState("idle");
+ const [statusKey, setStatusKey] = useState("statusIdle");
+ const [statusParams, setStatusParams] = useState<
+ Record | undefined
+ >(undefined);
const [roomCode, setRoomCode] = useState("");
const [streamUrl, setStreamUrl] = useState(null);
const [viewerCount, setViewerCount] = useState(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,
diff --git a/native-app/src/i18n/I18nProvider.tsx b/native-app/src/i18n/I18nProvider.tsx
new file mode 100644
index 0000000..1d100e0
--- /dev/null
+++ b/native-app/src/i18n/I18nProvider.tsx
@@ -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;
+}
+
+const I18nContext = createContext(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 {
+ 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(() => {
+ return {
+ locale,
+ t: (key, params) => translate(locale, key, params),
+ };
+ }, [locale]);
+
+ return {children};
+}
+
+export function useI18n(): I18nContextValue {
+ const context = useContext(I18nContext);
+
+ if (!context) {
+ throw new Error("useI18n must be used within I18nProvider");
+ }
+
+ return context;
+}
diff --git a/native-app/src/i18n/messages.ts b/native-app/src/i18n/messages.ts
new file mode 100644
index 0000000..6d61f15
--- /dev/null
+++ b/native-app/src/i18n/messages.ts
@@ -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;
+
+export const messages: Record = {
+ 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}",
+ },
+};
diff --git a/native-app/src/screens/SignInScreen.tsx b/native-app/src/screens/SignInScreen.tsx
index 5badce3..861a9f1 100644
--- a/native-app/src/screens/SignInScreen.tsx
+++ b/native-app/src/screens/SignInScreen.tsx
@@ -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("");
const [password, setPassword] = useState("");
@@ -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 (
- Helium Native
- Sign in with Clerk
+ {t("appTitle")}
+ {t("signInSubtitle")}
{isLoaded ? (
- Sign in
+ {t("signIn")}
) : (
)}
diff --git a/native-app/src/screens/StreamerScreen.tsx b/native-app/src/screens/StreamerScreen.tsx
index c408323..dd65ce7 100644
--- a/native-app/src/screens/StreamerScreen.tsx
+++ b/native-app/src/screens/StreamerScreen.tsx
@@ -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([]);
const [presetId, setPresetId] = useState("");
const [iceServers, setIceServers] = useState([]);
- const [presetStatus, setPresetStatus] = useState("loading presets");
+ const [presetStatusKey, setPresetStatusKey] = useState("loadingPresets");
+ const [presetStatusParams, setPresetStatusParams] = useState<
+ Record | 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 (
- Helium Streamer
- Share your Android screen to Helium viewers
+ {t("streamerTitle")}
+ {t("streamerSubtitle")}
- Preset
- {presetStatus}
+ {t("preset")}
+ {t(presetStatusKey, presetStatusParams)}
{presets.map((preset) => {
@@ -117,7 +129,7 @@ export function StreamerScreen() {
]}
>
{preset.preset.name}
- {preset.isDefault ? " (default)" : ""}
+ {preset.isDefault ? ` (${t("defaultLabel")})` : ""}
);
@@ -126,9 +138,9 @@ export function StreamerScreen() {
- Session
- Status: {status}
- Viewers: {viewerCount}
+ {t("session")}
+ {t("status")}: {t(statusKey, statusParams)}
+ {t("viewers")}: {viewerCount}
{roomCode || "------"}
@@ -138,11 +150,11 @@ export function StreamerScreen() {
}}
style={styles.primaryButton}
>
- Start screen share
+ {t("startShare")}
- Stop
+ {t("stop")}
@@ -150,10 +162,10 @@ export function StreamerScreen() {
{isSharing ? (
- Screen capture active. Preview disabled to reduce latency.
+ {t("previewActive")}
) : (
- Start sharing to broadcast this phone screen
+ {t("previewIdle")}
)}
@@ -163,7 +175,7 @@ export function StreamerScreen() {
}}
style={styles.signOutButton}
>
- Sign out
+ {t("signOut")}