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")}