From 38a557bd79bfca14599a580c871ae6de760ad5dc Mon Sep 17 00:00:00 2001 From: Izan Gil <66965250+SrIzan10@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:33:25 +0100 Subject: [PATCH] feat(native): add Android screen-share host with presets --- native-app/App.tsx | 18 +- native-app/README.md | 25 +- native-app/src/hooks/useHeliumStreamer.ts | 264 +++++++++++++++++++ native-app/src/hooks/useHeliumViewer.ts | 224 ---------------- native-app/src/lib/presets.ts | 53 ++++ native-app/src/lib/theme.ts | 54 ++++ native-app/src/screens/SignInScreen.tsx | 100 ++++---- native-app/src/screens/StreamerScreen.tsx | 295 ++++++++++++++++++++++ native-app/src/screens/ViewerScreen.tsx | 150 ----------- native-app/src/types/presets.ts | 33 +++ native-app/src/types/signaling.ts | 41 ++- 11 files changed, 815 insertions(+), 442 deletions(-) create mode 100644 native-app/src/hooks/useHeliumStreamer.ts delete mode 100644 native-app/src/hooks/useHeliumViewer.ts create mode 100644 native-app/src/lib/presets.ts create mode 100644 native-app/src/lib/theme.ts create mode 100644 native-app/src/screens/StreamerScreen.tsx delete mode 100644 native-app/src/screens/ViewerScreen.tsx create mode 100644 native-app/src/types/presets.ts diff --git a/native-app/App.tsx b/native-app/App.tsx index 6c17362..1ed6fe1 100644 --- a/native-app/App.tsx +++ b/native-app/App.tsx @@ -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 ( - - + + ); } @@ -26,17 +28,19 @@ function AuthReadyGate() { - + ); } export default function App() { + const theme = useAppTheme(); + if (!publishableKey) { return ( - - + + Missing EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY @@ -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", diff --git a/native-app/README.md b/native-app/README.md index c67c45e..6435feb 100644 --- a/native-app/README.md +++ b/native-app/README.md @@ -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 diff --git a/native-app/src/hooks/useHeliumStreamer.ts b/native-app/src/hooks/useHeliumStreamer.ts new file mode 100644 index 0000000..2aecc05 --- /dev/null +++ b/native-app/src/hooks/useHeliumStreamer.ts @@ -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; + stopSharing: () => void; +} + +export function useHeliumStreamer( + iceServers: NativeIceServer[], +): UseHeliumStreamerResult { + const wsRef = useRef(null); + const streamRef = useRef(null); + const heartbeatRef = useRef | null>(null); + const peersRef = useRef>({}); + + const [status, setStatus] = useState("idle"); + const [roomCode, setRoomCode] = useState(""); + const [streamUrl, setStreamUrl] = useState(null); + const [viewerCount, setViewerCount] = useState(0); + const [isSharing, setIsSharing] = useState(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 => { + 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): Promise => { + 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 => { + 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, + }; +} diff --git a/native-app/src/hooks/useHeliumViewer.ts b/native-app/src/hooks/useHeliumViewer.ts deleted file mode 100644 index a055657..0000000 --- a/native-app/src/hooks/useHeliumViewer.ts +++ /dev/null @@ -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(null); - const pcRef = useRef(null); - const targetPeerIdRef = useRef(null); - const heartbeatRef = useRef | null>(null); - - const [status, setStatus] = useState("idle"); - const [streamUrl, setStreamUrl] = useState(null); - const [isConnected, setIsConnected] = useState(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 => { - 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): Promise => { - 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, - }; -} diff --git a/native-app/src/lib/presets.ts b/native-app/src/lib/presets.ts new file mode 100644 index 0000000..f663c35 --- /dev/null +++ b/native-app/src/lib/presets.ts @@ -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( + path: string, + token: string, +): Promise { + 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 { + const payload = await fetchWithAuth("/api/presets", token); + return payload.data ?? []; +} + +export async function getPresetIceServers( + token: string, + presetId: string, +): Promise { + const payload = await fetchWithAuth( + `/api/presets/${presetId}`, + token, + ); + const rawServers = payload.data?.iceServers; + + if (typeof rawServers === "string") { + return JSON.parse(rawServers) as NativeIceServer[]; + } + + return rawServers ?? []; +} diff --git a/native-app/src/lib/theme.ts b/native-app/src/lib/theme.ts new file mode 100644 index 0000000..2c273c1 --- /dev/null +++ b/native-app/src/lib/theme.ts @@ -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; +} diff --git a/native-app/src/screens/SignInScreen.tsx b/native-app/src/screens/SignInScreen.tsx index 5182a03..5badce3 100644 --- a/native-app/src/screens/SignInScreen.tsx +++ b/native-app/src/screens/SignInScreen.tsx @@ -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(""); const [password, setPassword] = useState(""); const [status, setStatus] = useState(""); + const styles = createStyles(theme); const onSignIn = async (): Promise => { if (!isLoaded) { @@ -73,7 +77,7 @@ export function SignInScreen() { {isLoaded ? ( Sign in ) : ( - + )} @@ -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) { + 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", + }, + }); +} diff --git a/native-app/src/screens/StreamerScreen.tsx b/native-app/src/screens/StreamerScreen.tsx new file mode 100644 index 0000000..b0503d4 --- /dev/null +++ b/native-app/src/screens/StreamerScreen.tsx @@ -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([]); + const [presetId, setPresetId] = useState(""); + const [iceServers, setIceServers] = useState([]); + const [presetStatus, setPresetStatus] = useState("loading presets"); + + const styles = useMemo(() => createStyles(theme), [theme]); + + const { + status, + roomCode, + viewerCount, + streamUrl, + isSharing, + startSharing, + stopSharing, + } = useHeliumStreamer(iceServers); + + useEffect(() => { + const loadPresets = async (): Promise => { + 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 => { + 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 ( + + + Helium Streamer + Share your Android screen to Helium viewers + + + Preset + {presetStatus} + + + {presets.map((preset) => { + const selected = presetId === preset.presetId; + return ( + { + setPresetId(preset.presetId); + }} + style={[styles.presetItem, selected ? styles.presetItemSelected : null]} + > + + {preset.preset.name} + {preset.isDefault ? " (default)" : ""} + + + ); + })} + + + + + Session + Status: {status} + Viewers: {viewerCount} + {roomCode || "------"} + + + { + void startSharing(); + }} + style={styles.primaryButton} + > + Start screen share + + + + Stop + + + + + + {isSharing && streamUrl ? ( + + ) : ( + Screen preview appears after sharing starts + )} + + + { + void signOut(); + }} + style={styles.signOutButton} + > + Sign out + + + + ); +} + +function createStyles(theme: ReturnType) { + 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", + }, + }); +} diff --git a/native-app/src/screens/ViewerScreen.tsx b/native-app/src/screens/ViewerScreen.tsx deleted file mode 100644 index f9cd88a..0000000 --- a/native-app/src/screens/ViewerScreen.tsx +++ /dev/null @@ -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(""); - const { connect, disconnect, isConnected, status, streamUrl } = useHeliumViewer(); - - return ( - - - Helium Viewer - {status} - - { - setRoomCode(text.replace(/\D/g, "")); - }} - placeholder="Enter 6-digit room code" - placeholderTextColor="#6a7a8e" - style={styles.input} - value={roomCode} - /> - - - { - connect(roomCode); - }} - style={styles.primaryButton} - > - Connect - - - - Disconnect - - - { - void signOut(); - }} - style={styles.secondaryButton} - > - Sign out - - - - - {isConnected && streamUrl ? ( - - ) : ( - No stream yet - )} - - - - ); -} - -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", - }, -}); diff --git a/native-app/src/types/presets.ts b/native-app/src/types/presets.ts new file mode 100644 index 0000000..219b976 --- /dev/null +++ b/native-app/src/types/presets.ts @@ -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; +} diff --git a/native-app/src/types/signaling.ts b/native-app/src/types/signaling.ts index 7574970..01ba1e4 100644 --- a/native-app/src/types/signaling.ts +++ b/native-app/src/types/signaling.ts @@ -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" };