feat(native): add Android screen-share host with presets

This commit is contained in:
2026-02-13 18:33:25 +01:00
parent b088b65dad
commit 38a557bd79
11 changed files with 815 additions and 442 deletions

View File

@@ -4,18 +4,20 @@ 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 { useAppTheme } from "./src/lib/theme";
import { SignInScreen } from "./src/screens/SignInScreen";
import { ViewerScreen } from "./src/screens/ViewerScreen";
import { StreamerScreen } from "./src/screens/StreamerScreen";
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
function AuthReadyGate() {
const theme = useAppTheme();
const { isLoaded } = useAuth();
if (!isLoaded) {
return (
<View style={styles.loadingWrap}>
<ActivityIndicator color="#125dc6" size="large" />
<View style={[styles.loadingWrap, { backgroundColor: theme.background }]}>
<ActivityIndicator color={theme.primary} size="large" />
</View>
);
}
@@ -26,17 +28,19 @@ function AuthReadyGate() {
<SignInScreen />
</SignedOut>
<SignedIn>
<ViewerScreen />
<StreamerScreen />
</SignedIn>
</>
);
}
export default function App() {
const theme = useAppTheme();
if (!publishableKey) {
return (
<View style={styles.loadingWrap}>
<Text style={styles.errorText}>
<View style={[styles.loadingWrap, { backgroundColor: theme.background }]}>
<Text style={[styles.errorText, { color: theme.destructive }]}>
Missing EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY
</Text>
</View>
@@ -53,12 +57,10 @@ export default function App() {
const styles = StyleSheet.create({
loadingWrap: {
alignItems: "center",
backgroundColor: "#eef4fa",
flex: 1,
justifyContent: "center",
},
errorText: {
color: "#9b1026",
fontSize: 16,
paddingHorizontal: 20,
textAlign: "center",

View File

@@ -1,11 +1,13 @@
# Helium Native (Expo + React Native)
Simple React Native viewer app that:
Simple React Native streamer app that:
- Authenticates with Clerk (`@clerk/clerk-expo`)
- Connects to Helium signaling at `/ws/signaling`
- Joins a 6-digit room and answers WebRTC offers
- Renders incoming stream with `RTCView`
- Fetches your Helium presets from `/api/presets`
- Loads selected preset ICE servers from `/api/presets/:id`
- 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
## Auth implementation notes (from Clerk docs via Context7)
@@ -41,14 +43,15 @@ pnpm -C native-app prebuild
pnpm -C native-app android
```
## Signaling protocol wired
## Host signaling protocol wired
Implemented in `native-app/src/hooks/useHeliumViewer.ts`:
Implemented in `native-app/src/hooks/useHeliumStreamer.ts`:
- send `join-room`
- receive `offer`
- create peer connection with provided `iceServers`
- set remote description and send `answer`
- send `create-room`
- receive `viewer-joined`
- create peer connection with selected preset `iceServers`
- send `offer` for each viewer
- receive `answer`
- exchange `ice-candidate`
- handle `room-closed`
- handle `viewer-left`
- heartbeat with `ping` every 15s

View File

@@ -0,0 +1,264 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
MediaStream,
RTCPeerConnection,
RTCIceCandidate,
RTCSessionDescription,
mediaDevices,
} from "react-native-webrtc";
import { getSignalingUrl } from "../lib/signaling";
import type {
IncomingSignalingMessage,
NativeIceServer,
NativeSessionDescriptionInit,
} from "../types/signaling";
interface PeerConnectionHandlers {
onicecandidate:
| ((event: { candidate: RTCIceCandidate | null }) => void)
| null;
onconnectionstatechange: (() => void) | null;
}
interface UseHeliumStreamerResult {
status: string;
roomCode: string;
streamUrl: string | null;
viewerCount: number;
isSharing: boolean;
startSharing: () => Promise<void>;
stopSharing: () => void;
}
export function useHeliumStreamer(
iceServers: NativeIceServer[],
): UseHeliumStreamerResult {
const wsRef = useRef<WebSocket | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const peersRef = useRef<Record<string, RTCPeerConnection>>({});
const [status, setStatus] = useState<string>("idle");
const [roomCode, setRoomCode] = useState<string>("");
const [streamUrl, setStreamUrl] = useState<string | null>(null);
const [viewerCount, setViewerCount] = useState<number>(0);
const [isSharing, setIsSharing] = useState<boolean>(false);
const sendMessage = useCallback((payload: object): void => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) {
return;
}
ws.send(JSON.stringify(payload));
}, []);
const closeAllPeers = useCallback((): void => {
Object.values(peersRef.current).forEach((peer) => {
peer.close();
});
peersRef.current = {};
setViewerCount(0);
}, []);
const stopSharing = useCallback((): void => {
if (heartbeatRef.current) {
clearInterval(heartbeatRef.current);
heartbeatRef.current = null;
}
closeAllPeers();
const ws = wsRef.current;
if (ws) {
ws.close();
wsRef.current = null;
}
const localStream = streamRef.current;
if (localStream) {
localStream.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
}
setRoomCode("");
setStreamUrl(null);
setIsSharing(false);
setStatus("stopped");
}, [closeAllPeers]);
const handleViewerJoined = useCallback(
async (viewerId: string): Promise<void> => {
const localStream = streamRef.current;
if (!localStream) {
return;
}
const peer = new RTCPeerConnection({
iceServers,
});
const peerWithHandlers = peer as RTCPeerConnection & PeerConnectionHandlers;
peersRef.current[viewerId] = peer;
setViewerCount(Object.keys(peersRef.current).length);
localStream.getTracks().forEach((track) => {
peer.addTrack(track, localStream);
});
peerWithHandlers.onicecandidate = (event): void => {
if (!event.candidate) {
return;
}
sendMessage({
event: "ice-candidate",
targetId: viewerId,
candidate: event.candidate,
});
};
peerWithHandlers.onconnectionstatechange = (): void => {
setStatus(`viewer ${viewerId}: ${peer.connectionState}`);
};
const offer = (await peer.createOffer()) as NativeSessionDescriptionInit;
await peer.setLocalDescription(offer);
sendMessage({
event: "offer",
targetId: viewerId,
sdp: offer,
iceServers,
});
},
[iceServers, sendMessage],
);
const handleIncomingMessage = useCallback(
async (event: MessageEvent<string>): Promise<void> => {
const message = JSON.parse(event.data) as IncomingSignalingMessage;
if (message.event === "room-created") {
setRoomCode(message.roomId);
setStatus(`room code: ${message.roomId}`);
return;
}
if (message.event === "viewer-joined") {
setStatus(`viewer joined: ${message.viewerId}`);
await handleViewerJoined(message.viewerId);
return;
}
if (message.event === "answer") {
const peer = peersRef.current[message.from];
if (!peer) {
return;
}
await peer.setRemoteDescription(new RTCSessionDescription(message.sdp));
return;
}
if (message.event === "ice-candidate") {
const peer = peersRef.current[message.from];
if (!peer || !peer.remoteDescription) {
return;
}
await peer.addIceCandidate(new RTCIceCandidate(message.candidate));
return;
}
if (message.event === "viewer-left") {
const peer = peersRef.current[message.viewerId];
if (peer) {
peer.close();
delete peersRef.current[message.viewerId];
setViewerCount(Object.keys(peersRef.current).length);
}
return;
}
if (message.event === "error") {
setStatus(`error: ${message.message}`);
}
},
[handleViewerJoined],
);
const startSharing = useCallback(async (): Promise<void> => {
stopSharing();
if (!iceServers.length) {
setStatus("no preset selected");
return;
}
setStatus("requesting screen capture");
const stream = await mediaDevices.getDisplayMedia();
streamRef.current = stream;
setStreamUrl(stream.toURL());
setIsSharing(true);
stream.getTracks().forEach((track) => {
const streamTrack = track as unknown as MediaStreamTrack & {
onended: (() => void) | null;
};
streamTrack.onended = () => {
stopSharing();
};
});
setStatus("connecting signaling");
const ws = new WebSocket(getSignalingUrl());
wsRef.current = ws;
ws.onopen = (): void => {
setStatus("creating room");
sendMessage({ event: "create-room" });
heartbeatRef.current = setInterval(() => {
sendMessage({ event: "ping" });
}, 15000);
};
ws.onmessage = (message): void => {
void handleIncomingMessage(message);
};
ws.onerror = (): void => {
setStatus("websocket error");
};
ws.onclose = (): void => {
if (heartbeatRef.current) {
clearInterval(heartbeatRef.current);
heartbeatRef.current = null;
}
if (isSharing) {
setStatus("websocket closed");
}
};
}, [handleIncomingMessage, iceServers, isSharing, sendMessage, stopSharing]);
useEffect(() => {
return () => {
stopSharing();
};
}, [stopSharing]);
return {
status,
roomCode,
streamUrl,
viewerCount,
isSharing,
startSharing,
stopSharing,
};
}

View File

@@ -1,224 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
MediaStream,
RTCPeerConnection,
RTCIceCandidate,
RTCSessionDescription,
} from "react-native-webrtc";
import { getSignalingUrl } from "../lib/signaling";
import type {
IncomingSignalingMessage,
SignalingOfferEvent,
} from "../types/signaling";
interface UseHeliumViewerResult {
status: string;
streamUrl: string | null;
connect: (roomId: string) => void;
disconnect: () => void;
isConnected: boolean;
}
interface PeerConnectionHandlers {
ontrack: ((event: { streams?: MediaStream[] }) => void) | null;
onicecandidate:
| ((event: { candidate: RTCIceCandidate | null }) => void)
| null;
onconnectionstatechange: (() => void) | null;
}
export function useHeliumViewer(): UseHeliumViewerResult {
const wsRef = useRef<WebSocket | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const targetPeerIdRef = useRef<string | null>(null);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [status, setStatus] = useState<string>("idle");
const [streamUrl, setStreamUrl] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState<boolean>(false);
const sendMessage = useCallback((payload: object): void => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) {
return;
}
ws.send(JSON.stringify(payload));
}, []);
const cleanupPeerConnection = useCallback((): void => {
const pc = pcRef.current;
if (pc) {
pc.close();
pcRef.current = null;
}
targetPeerIdRef.current = null;
setIsConnected(false);
}, []);
const disconnect = useCallback((): void => {
if (heartbeatRef.current) {
clearInterval(heartbeatRef.current);
heartbeatRef.current = null;
}
cleanupPeerConnection();
setStreamUrl(null);
const ws = wsRef.current;
if (ws) {
ws.close();
wsRef.current = null;
}
setStatus("disconnected");
}, [cleanupPeerConnection]);
const handleOffer = useCallback(
async (message: SignalingOfferEvent): Promise<void> => {
setStatus("received offer");
cleanupPeerConnection();
targetPeerIdRef.current = message.senderId;
const pc = new RTCPeerConnection({
iceServers: message.iceServers ?? [],
});
const pcWithHandlers = pc as RTCPeerConnection & PeerConnectionHandlers;
pcRef.current = pc;
pcWithHandlers.ontrack = (event): void => {
const stream = event.streams?.[0] as MediaStream | undefined;
if (!stream) {
return;
}
setStreamUrl(stream.toURL());
};
pcWithHandlers.onicecandidate = (event): void => {
if (!event.candidate || !targetPeerIdRef.current) {
return;
}
sendMessage({
event: "ice-candidate",
targetId: targetPeerIdRef.current,
candidate: event.candidate,
});
};
pcWithHandlers.onconnectionstatechange = (): void => {
setStatus(`peer: ${pc.connectionState}`);
if (pc.connectionState === "connected") {
setIsConnected(true);
}
if (pc.connectionState === "failed" || pc.connectionState === "closed") {
setIsConnected(false);
}
};
await pc.setRemoteDescription(new RTCSessionDescription(message.sdp));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sendMessage({
event: "answer",
targetId: message.senderId,
sdp: answer,
});
setStatus("sent answer");
},
[cleanupPeerConnection, sendMessage],
);
const handleIncomingMessage = useCallback(
async (event: MessageEvent<string>): Promise<void> => {
const message = JSON.parse(event.data) as IncomingSignalingMessage;
if (message.event === "joined") {
setStatus(`joined room ${message.roomId}`);
return;
}
if (message.event === "offer") {
await handleOffer(message);
return;
}
if (message.event === "ice-candidate") {
const pc = pcRef.current;
if (!pc || !pc.remoteDescription) {
return;
}
await pc.addIceCandidate(new RTCIceCandidate(message.candidate));
return;
}
if (message.event === "room-closed") {
disconnect();
setStatus("room closed by host");
return;
}
if (message.event === "error") {
setStatus(`error: ${message.message}`);
}
},
[disconnect, handleOffer],
);
const connect = useCallback(
(roomId: string): void => {
disconnect();
setStatus("connecting websocket");
const ws = new WebSocket(getSignalingUrl());
wsRef.current = ws;
ws.onopen = (): void => {
setStatus("websocket connected");
sendMessage({ event: "join-room", roomId });
heartbeatRef.current = setInterval(() => {
sendMessage({ event: "ping" });
}, 15000);
};
ws.onmessage = (event): void => {
void handleIncomingMessage(event);
};
ws.onerror = (): void => {
setStatus("websocket error");
};
ws.onclose = (): void => {
if (heartbeatRef.current) {
clearInterval(heartbeatRef.current);
heartbeatRef.current = null;
}
setStatus("websocket closed");
setIsConnected(false);
};
},
[disconnect, handleIncomingMessage, sendMessage],
);
useEffect(() => {
return () => {
disconnect();
};
}, [disconnect]);
return {
status,
streamUrl,
connect,
disconnect,
isConnected,
};
}

View File

@@ -0,0 +1,53 @@
import { getHeliumBaseUrl } from "./signaling";
import type {
NativeIceServer,
PresetResponse,
PresetsResponse,
PresetUser,
} from "../types/presets";
interface ApiErrorResponse {
statusCode?: number;
message?: string;
}
async function fetchWithAuth<T>(
path: string,
token: string,
): Promise<T> {
const response = await fetch(`${getHeliumBaseUrl()}${path}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as ApiErrorResponse;
const message = body.message ?? `Request failed: ${response.status}`;
throw new Error(message);
}
return (await response.json()) as T;
}
export async function getPresets(token: string): Promise<PresetUser[]> {
const payload = await fetchWithAuth<PresetsResponse>("/api/presets", token);
return payload.data ?? [];
}
export async function getPresetIceServers(
token: string,
presetId: string,
): Promise<NativeIceServer[]> {
const payload = await fetchWithAuth<PresetResponse>(
`/api/presets/${presetId}`,
token,
);
const rawServers = payload.data?.iceServers;
if (typeof rawServers === "string") {
return JSON.parse(rawServers) as NativeIceServer[];
}
return rawServers ?? [];
}

View File

@@ -0,0 +1,54 @@
import { useColorScheme } from "react-native";
export interface AppTheme {
background: string;
foreground: string;
card: string;
border: string;
input: string;
muted: string;
mutedForeground: string;
primary: string;
primaryForeground: string;
secondary: string;
secondaryForeground: string;
accent: string;
destructive: string;
}
const lightTheme: AppTheme = {
background: "#f0eff5",
foreground: "#4f4c64",
card: "#eceaf2",
border: "#e1dee9",
input: "#dfdce8",
muted: "#e7e5ee",
mutedForeground: "#66637d",
primary: "#a43ad7",
primaryForeground: "#ffffff",
secondary: "#be9bcd",
secondaryForeground: "#3f3452",
accent: "#d0cee0",
destructive: "#b4435a",
};
const darkTheme: AppTheme = {
background: "#30273b",
foreground: "#e4deec",
card: "#2a2234",
border: "#494055",
input: "#534a5f",
muted: "#3b3347",
mutedForeground: "#bbb3c7",
primary: "#d28ee8",
primaryForeground: "#48245f",
secondary: "#6d4a82",
secondaryForeground: "#eadcf1",
accent: "#5a5268",
destructive: "#d46f7a",
};
export function useAppTheme(): AppTheme {
const colorScheme = useColorScheme();
return colorScheme === "dark" ? darkTheme : lightTheme;
}

View File

@@ -9,11 +9,15 @@ import {
View,
} from "react-native";
import { useAppTheme } from "../lib/theme";
export function SignInScreen() {
const theme = useAppTheme();
const { isLoaded, signIn, setActive } = useSignIn();
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [status, setStatus] = useState<string>("");
const styles = createStyles(theme);
const onSignIn = async (): Promise<void> => {
if (!isLoaded) {
@@ -73,7 +77,7 @@ export function SignInScreen() {
{isLoaded ? (
<Text style={styles.buttonText}>Sign in</Text>
) : (
<ActivityIndicator color="#ffffff" />
<ActivityIndicator color={theme.primaryForeground} />
)}
</Pressable>
@@ -82,49 +86,51 @@ export function SignInScreen() {
);
}
const styles = StyleSheet.create({
container: {
alignItems: "stretch",
backgroundColor: "#f4f8fb",
flex: 1,
gap: 12,
justifyContent: "center",
padding: 24,
},
title: {
color: "#0f1f33",
fontSize: 28,
fontWeight: "700",
textAlign: "center",
},
subtitle: {
color: "#4a5f79",
marginBottom: 12,
textAlign: "center",
},
input: {
backgroundColor: "#ffffff",
borderColor: "#c8d7ea",
borderRadius: 12,
borderWidth: 1,
color: "#11243d",
paddingHorizontal: 14,
paddingVertical: 12,
},
button: {
alignItems: "center",
backgroundColor: "#1366d6",
borderRadius: 12,
paddingVertical: 12,
},
buttonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "700",
},
status: {
color: "#405166",
fontSize: 13,
textAlign: "center",
},
});
function createStyles(theme: ReturnType<typeof useAppTheme>) {
return StyleSheet.create({
container: {
alignItems: "stretch",
backgroundColor: theme.background,
flex: 1,
gap: 12,
justifyContent: "center",
padding: 24,
},
title: {
color: theme.foreground,
fontSize: 28,
fontWeight: "700",
textAlign: "center",
},
subtitle: {
color: theme.mutedForeground,
marginBottom: 12,
textAlign: "center",
},
input: {
backgroundColor: theme.input,
borderColor: theme.border,
borderRadius: 12,
borderWidth: 1,
color: theme.foreground,
paddingHorizontal: 14,
paddingVertical: 12,
},
button: {
alignItems: "center",
backgroundColor: theme.primary,
borderRadius: 12,
paddingVertical: 12,
},
buttonText: {
color: theme.primaryForeground,
fontSize: 16,
fontWeight: "700",
},
status: {
color: theme.mutedForeground,
fontSize: 13,
textAlign: "center",
},
});
}

View File

@@ -0,0 +1,295 @@
import { useAuth } from "@clerk/clerk-expo";
import { useEffect, useMemo, useState } from "react";
import {
Pressable,
SafeAreaView,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { RTCView } from "react-native-webrtc";
import { useHeliumStreamer } from "../hooks/useHeliumStreamer";
import { useAppTheme } from "../lib/theme";
import { getPresetIceServers, getPresets } from "../lib/presets";
import type { NativeIceServer, PresetUser } from "../types/presets";
export function StreamerScreen() {
const { getToken, signOut } = useAuth();
const theme = useAppTheme();
const [presets, setPresets] = useState<PresetUser[]>([]);
const [presetId, setPresetId] = useState<string>("");
const [iceServers, setIceServers] = useState<NativeIceServer[]>([]);
const [presetStatus, setPresetStatus] = useState<string>("loading presets");
const styles = useMemo(() => createStyles(theme), [theme]);
const {
status,
roomCode,
viewerCount,
streamUrl,
isSharing,
startSharing,
stopSharing,
} = useHeliumStreamer(iceServers);
useEffect(() => {
const loadPresets = async (): Promise<void> => {
const token = await getToken();
if (!token) {
setPresetStatus("could not read auth token");
return;
}
try {
const availablePresets = await getPresets(token);
setPresets(availablePresets);
if (!availablePresets.length) {
setPresetStatus("no presets found");
return;
}
const defaultPreset =
availablePresets.find((preset) => preset.isDefault) ?? availablePresets[0];
setPresetId(defaultPreset.presetId);
} catch (error) {
setPresetStatus(`failed to load presets: ${(error as Error).message}`);
}
};
void loadPresets();
}, [getToken]);
useEffect(() => {
const loadIceServers = async (): Promise<void> => {
if (!presetId) {
return;
}
const token = await getToken();
if (!token) {
setPresetStatus("missing auth token for preset");
return;
}
try {
const servers = await getPresetIceServers(token, presetId);
setIceServers(servers);
setPresetStatus(`loaded ${servers.length} ICE server entries`);
} catch (error) {
setPresetStatus(`failed preset load: ${(error as Error).message}`);
}
};
void loadIceServers();
}, [getToken, presetId]);
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>
<View style={styles.card}>
<Text style={styles.cardTitle}>Preset</Text>
<Text style={styles.small}>{presetStatus}</Text>
<View style={styles.presetList}>
{presets.map((preset) => {
const selected = presetId === preset.presetId;
return (
<Pressable
key={preset.presetId}
onPress={() => {
setPresetId(preset.presetId);
}}
style={[styles.presetItem, selected ? styles.presetItemSelected : null]}
>
<Text
style={[
styles.presetItemText,
selected ? styles.presetItemTextSelected : null,
]}
>
{preset.preset.name}
{preset.isDefault ? " (default)" : ""}
</Text>
</Pressable>
);
})}
</View>
</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.roomCode}>{roomCode || "------"}</Text>
<View style={styles.actions}>
<Pressable
onPress={() => {
void startSharing();
}}
style={styles.primaryButton}
>
<Text style={styles.primaryButtonText}>Start screen share</Text>
</Pressable>
<Pressable onPress={stopSharing} style={styles.secondaryButton}>
<Text style={styles.secondaryButtonText}>Stop</Text>
</Pressable>
</View>
</View>
<View style={styles.preview}>
{isSharing && streamUrl ? (
<RTCView mirror={false} objectFit="contain" streamURL={streamUrl} style={styles.video} />
) : (
<Text style={styles.previewPlaceholder}>Screen preview appears after sharing starts</Text>
)}
</View>
<Pressable
onPress={() => {
void signOut();
}}
style={styles.signOutButton}
>
<Text style={styles.signOutText}>Sign out</Text>
</Pressable>
</ScrollView>
</SafeAreaView>
);
}
function createStyles(theme: ReturnType<typeof useAppTheme>) {
return StyleSheet.create({
safeArea: {
backgroundColor: theme.background,
flex: 1,
},
container: {
gap: 12,
padding: 16,
paddingBottom: 28,
},
title: {
color: theme.foreground,
fontSize: 24,
fontWeight: "700",
},
subtitle: {
color: theme.mutedForeground,
marginTop: -6,
},
card: {
backgroundColor: theme.card,
borderColor: theme.border,
borderRadius: 14,
borderWidth: 1,
gap: 8,
padding: 12,
},
cardTitle: {
color: theme.foreground,
fontSize: 17,
fontWeight: "700",
},
small: {
color: theme.mutedForeground,
fontSize: 13,
},
presetList: {
gap: 8,
},
presetItem: {
backgroundColor: theme.input,
borderColor: theme.border,
borderRadius: 10,
borderWidth: 1,
paddingHorizontal: 10,
paddingVertical: 10,
},
presetItemSelected: {
backgroundColor: theme.secondary,
borderColor: theme.primary,
},
presetItemText: {
color: theme.foreground,
fontWeight: "600",
},
presetItemTextSelected: {
color: theme.secondaryForeground,
},
roomCode: {
color: theme.primary,
fontSize: 34,
fontWeight: "800",
letterSpacing: 2,
marginTop: 6,
},
actions: {
flexDirection: "row",
gap: 8,
marginTop: 6,
},
primaryButton: {
alignItems: "center",
backgroundColor: theme.primary,
borderRadius: 10,
flex: 1,
paddingVertical: 11,
},
primaryButtonText: {
color: theme.primaryForeground,
fontWeight: "700",
},
secondaryButton: {
alignItems: "center",
backgroundColor: theme.accent,
borderRadius: 10,
justifyContent: "center",
paddingHorizontal: 16,
paddingVertical: 11,
},
secondaryButtonText: {
color: theme.foreground,
fontWeight: "700",
},
preview: {
alignItems: "center",
backgroundColor: "#000000",
borderRadius: 14,
height: 220,
justifyContent: "center",
overflow: "hidden",
},
video: {
height: "100%",
width: "100%",
},
previewPlaceholder: {
color: theme.mutedForeground,
paddingHorizontal: 16,
textAlign: "center",
},
signOutButton: {
alignItems: "center",
borderColor: theme.destructive,
borderRadius: 10,
borderWidth: 1,
paddingVertical: 10,
},
signOutText: {
color: theme.destructive,
fontWeight: "700",
},
});
}

View File

@@ -1,150 +0,0 @@
import { useState } from "react";
import { useAuth } from "@clerk/clerk-expo";
import {
Pressable,
SafeAreaView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { RTCView } from "react-native-webrtc";
import { useHeliumViewer } from "../hooks/useHeliumViewer";
export function ViewerScreen() {
const { signOut } = useAuth();
const [roomCode, setRoomCode] = useState<string>("");
const { connect, disconnect, isConnected, status, streamUrl } = useHeliumViewer();
return (
<SafeAreaView style={styles.safeArea}>
<View style={styles.container}>
<Text style={styles.title}>Helium Viewer</Text>
<Text style={styles.status}>{status}</Text>
<TextInput
autoCapitalize="none"
keyboardType="number-pad"
maxLength={6}
onChangeText={(text: string) => {
setRoomCode(text.replace(/\D/g, ""));
}}
placeholder="Enter 6-digit room code"
placeholderTextColor="#6a7a8e"
style={styles.input}
value={roomCode}
/>
<View style={styles.actions}>
<Pressable
disabled={roomCode.length !== 6}
onPress={() => {
connect(roomCode);
}}
style={styles.primaryButton}
>
<Text style={styles.primaryButtonText}>Connect</Text>
</Pressable>
<Pressable onPress={disconnect} style={styles.secondaryButton}>
<Text style={styles.secondaryButtonText}>Disconnect</Text>
</Pressable>
<Pressable
onPress={() => {
void signOut();
}}
style={styles.secondaryButton}
>
<Text style={styles.secondaryButtonText}>Sign out</Text>
</Pressable>
</View>
<View style={styles.videoWrap}>
{isConnected && streamUrl ? (
<RTCView
mirror={false}
objectFit="contain"
streamURL={streamUrl}
style={styles.video}
/>
) : (
<Text style={styles.placeholder}>No stream yet</Text>
)}
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
backgroundColor: "#f0f6fd",
flex: 1,
},
container: {
flex: 1,
gap: 12,
padding: 18,
},
title: {
color: "#12263c",
fontSize: 22,
fontWeight: "700",
},
status: {
color: "#4f6278",
fontSize: 13,
},
input: {
backgroundColor: "#ffffff",
borderColor: "#d3deeb",
borderRadius: 12,
borderWidth: 1,
color: "#10233b",
fontSize: 16,
paddingHorizontal: 12,
paddingVertical: 10,
},
actions: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
primaryButton: {
backgroundColor: "#0e68de",
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 10,
},
primaryButtonText: {
color: "#ffffff",
fontWeight: "700",
},
secondaryButton: {
backgroundColor: "#e4edf8",
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 10,
},
secondaryButtonText: {
color: "#21354d",
fontWeight: "600",
},
videoWrap: {
alignItems: "center",
backgroundColor: "#0a121e",
borderRadius: 14,
flex: 1,
justifyContent: "center",
overflow: "hidden",
},
video: {
height: "100%",
width: "100%",
},
placeholder: {
color: "#92a3b8",
},
});

View File

@@ -0,0 +1,33 @@
export interface Preset {
id: string;
name: string;
createdBy: string;
iceServers: string | NativeIceServer[];
shareable: boolean;
createdAt: string;
}
export interface NativeIceServer {
urls: string | string[];
username?: string;
credential?: string;
}
export interface PresetUser {
id: string;
presetId: string;
userId: string;
isDefault: boolean;
addedAt: string;
preset: Preset;
}
export interface PresetsResponse {
success: boolean;
data: PresetUser[];
}
export interface PresetResponse {
success: boolean;
data: Preset;
}

View File

@@ -3,17 +3,50 @@ export interface NativeSessionDescriptionInit {
sdp: string;
}
export interface NativeIceServer {
urls: string | string[];
username?: string;
credential?: string;
}
export interface NativeIceCandidateInit {
candidate: string;
sdpMid?: string | null;
sdpMLineIndex?: number | null;
}
export interface SignalingOfferEvent {
event: "offer";
sdp: NativeSessionDescriptionInit;
senderId: string;
iceServers?: RTCIceServer[];
iceServers?: NativeIceServer[];
}
export interface SignalingIceCandidateEvent {
event: "ice-candidate";
from: string;
candidate: RTCIceCandidateInit;
candidate: NativeIceCandidateInit;
}
export interface SignalingViewerJoinedEvent {
event: "viewer-joined";
viewerId: string;
}
export interface SignalingAnswerEvent {
event: "answer";
from: string;
sdp: NativeSessionDescriptionInit;
}
export interface SignalingViewerLeftEvent {
event: "viewer-left";
viewerId: string;
}
export interface SignalingRoomCreatedEvent {
event: "room-created";
roomId: string;
}
export interface SignalingJoinedEvent {
@@ -36,4 +69,8 @@ export type IncomingSignalingMessage =
| SignalingJoinedEvent
| SignalingErrorEvent
| SignalingRoomClosedEvent
| SignalingViewerJoinedEvent
| SignalingViewerLeftEvent
| SignalingRoomCreatedEvent
| SignalingAnswerEvent
| { event: "pong" };