diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml new file mode 100644 index 0000000..17a1f8c --- /dev/null +++ b/.github/workflows/android-release.yml @@ -0,0 +1,77 @@ +name: Android Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Existing tag to publish the APK to" + required: true + type: string + +permissions: + contents: write + +jobs: + build-and-release: + runs-on: ubuntu-latest + + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Checkout selected tag + if: ${{ github.event_name == 'workflow_dispatch' }} + run: git checkout "refs/tags/${{ inputs.tag }}" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: gradle + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build release APK + run: ./gradlew assembleRelease + working-directory: native-app/android + + - name: Prepare APK asset + run: cp native-app/android/app/build/outputs/apk/release/app-release.apk helium-android-${{ env.RELEASE_TAG }}.apk + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: helium-android-${{ env.RELEASE_TAG }} + path: helium-android-${{ env.RELEASE_TAG }}.apk + if-no-files-found: error + + - name: Upload APK to GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.RELEASE_TAG }} + files: helium-android-${{ env.RELEASE_TAG }}.apk + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index aac2668..a18357b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,8 @@ electron/dist dist-electron # Node dependencies -node_modules +*node_modules/ +node_modules/ # Logs logs @@ -26,3 +27,10 @@ logs .env .env.* !.env.example + +*credentials.json + +native-app/.expo +native-app/android +native-app/ios +native-app/dist diff --git a/README.md b/README.md index 2d3ef2e..e543c9a 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,7 @@ effortless webrtc screensharing +# ai usage transparency + +i maintain full control of the website, while the android and electron apps are "controlled" by the ai. +i, of course, review code, but would like to implement stuff on \ No newline at end of file diff --git a/app.json b/app.json new file mode 100644 index 0000000..c9e8c8a --- /dev/null +++ b/app.json @@ -0,0 +1,3 @@ +{ + "expo": {} +} \ No newline at end of file diff --git a/app/layouts/default.vue b/app/layouts/default.vue index f7e28a5..a07b68b 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -33,6 +33,7 @@ const navLinks = [ { to: "/", label: "home" }, { to: "/stream", label: "stream" }, { to: "/about", label: "about" }, + { to: "/downloads", label: "downloads" }, { to: "/presets", label: "presets", requiresAuth: true }, ]; diff --git a/app/pages/downloads.vue b/app/pages/downloads.vue new file mode 100644 index 0000000..65d93d2 --- /dev/null +++ b/app/pages/downloads.vue @@ -0,0 +1,298 @@ + + + diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..eba8a89 --- /dev/null +++ b/eas.json @@ -0,0 +1,21 @@ +{ + "cli": { + "version": ">= 18.0.1", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 68dc59b..036b960 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -5,6 +5,7 @@ "home": "Home", "about": "About", "contact": "Contact", + "downloads": "Downloads", "stream": "Stream", "presets": "Presets", "effortlessScreensharing": "effortless screensharing powered by webrtc", @@ -62,5 +63,18 @@ "audioSupported": "Audio Supported", "failedToStartScreenShare": "Failed to start screen share.", "screenRecordingPermissionRequired": "macOS blocked screen capture. Allow Helium in System Settings > Privacy & Security > Screen Recording, then restart Helium.", - "screenRecordingPermissionRequiredNoShortcut": "macOS blocked screen capture. Open System Settings > Privacy & Security > Screen Recording, allow Helium, then restart Helium." + "screenRecordingPermissionRequiredNoShortcut": "macOS blocked screen capture. Open System Settings > Privacy & Security > Screen Recording, allow Helium, then restart Helium.", + "downloadHelium": "Download Helium", + "downloadHeliumDescription": "Get the best screensharing experience with our native apps. Available for desktop and Android.", + "desktopApp": "Desktop App", + "desktopAppDescription": "The full-featured Electron app with system audio support.", + "androidApp": "Android App", + "androidAppDescription": "Stream directly from your Android device.", + "desktopAppNote": "Includes advanced features like system audio capture, venmic support on Linux, and native screen recording permissions.", + "androidAppNote": "Install the APK directly on your Android device. Make sure to allow installation from unknown sources if prompted.", + "downloadFromGitHub": "Download from GitHub", + "viewSourceCode": "View Source Code", + "preferTheBrowser": "Prefer the browser?", + "browserVersionDescription": "The web version works great too. No installation required — just open the page and start streaming or watching.", + "useWebVersion": "Use Web Version" } diff --git a/i18n/locales/es.json b/i18n/locales/es.json index 87f578f..b15654a 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -5,6 +5,7 @@ "home": "Inicio", "about": "Acerca de", "contact": "Contacto", + "downloads": "Descargas", "stream": "Transmisión", "presets": "Ajustes predefinidos", "effortlessScreensharing": "comparte pantalla sin complicaciones", @@ -62,5 +63,18 @@ "audioSupported": "Audio soportado", "failedToStartScreenShare": "No se pudo iniciar el uso compartido de pantalla.", "screenRecordingPermissionRequired": "macOS bloqueó la captura de pantalla. Permite Helium en Configuración del Sistema > Privacidad y seguridad > Grabación de pantalla y luego reinicia Helium.", - "screenRecordingPermissionRequiredNoShortcut": "macOS bloqueó la captura de pantalla. Abre Configuración del Sistema > Privacidad y seguridad > Grabación de pantalla, permite Helium y luego reinicia Helium." + "screenRecordingPermissionRequiredNoShortcut": "macOS bloqueó la captura de pantalla. Abre Configuración del Sistema > Privacidad y seguridad > Grabación de pantalla, permite Helium y luego reinicia Helium.", + "downloadHelium": "Descargar Helium", + "downloadHeliumDescription": "Obtén la mejor experiencia de compartir pantalla con nuestras aplicaciones nativas. Disponible para escritorio y Android.", + "desktopApp": "Aplicación de Escritorio", + "desktopAppDescription": "La aplicación Electron completa con soporte de audio del sistema.", + "androidApp": "Aplicación Android", + "androidAppDescription": "Transmite directamente desde tu dispositivo Android.", + "desktopAppNote": "Incluye funciones avanzadas como captura de audio del sistema, soporte de venmic en Linux y permisos de grabación de pantalla nativos.", + "androidAppNote": "Instala el APK directamente en tu dispositivo Android. Asegúrate de permitir la instalación desde fuentes desconocidas si se te solicita.", + "downloadFromGitHub": "Descargar desde GitHub", + "viewSourceCode": "Ver Código Fuente", + "preferTheBrowser": "¿Prefieres el navegador?", + "browserVersionDescription": "La versión web también funciona muy bien. No requiere instalación: simplemente abre la página y empieza a transmitir o ver.", + "useWebVersion": "Usar Versión Web" } diff --git a/native-app/.gitignore b/native-app/.gitignore new file mode 100644 index 0000000..6c63ca9 --- /dev/null +++ b/native-app/.gitignore @@ -0,0 +1,5 @@ +node_modules +.expo +android +ios +dist diff --git a/native-app/App.tsx b/native-app/App.tsx new file mode 100644 index 0000000..fdd26dc --- /dev/null +++ b/native-app/App.tsx @@ -0,0 +1,78 @@ +import "react-native-url-polyfill/auto"; + +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"; + +const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY; + +function AuthReadyGate() { + const theme = useAppTheme(); + const { isLoaded } = useAuth(); + + if (!isLoaded) { + return ( + + + + ); + } + + return ( + <> + + + + + + + + ); +} + +export default function App() { + return ( + + + + ); +} + +function AppContent() { + const theme = useAppTheme(); + const { t } = useI18n(); + + if (!publishableKey) { + return ( + + + {t("missingClerkKey")} + + + ); + } + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + loadingWrap: { + alignItems: "center", + flex: 1, + justifyContent: "center", + }, + errorText: { + fontSize: 16, + paddingHorizontal: 20, + textAlign: "center", + }, +}); diff --git a/native-app/README.md b/native-app/README.md new file mode 100644 index 0000000..d130961 --- /dev/null +++ b/native-app/README.md @@ -0,0 +1,58 @@ +# Helium Native (Expo + React Native) + +Simple React Native streamer app that: + +- Authenticates with Clerk (`@clerk/clerk-expo`) +- 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 +- Includes built-in i18n for English and Spanish based on device locale + +## Auth implementation notes (from Clerk docs via Context7) + +This app follows Clerk Expo guidance: + +- Wrap app with `ClerkProvider` +- Use secure `tokenCache` from `@clerk/clerk-expo/token-cache` +- Configure `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` +- Use `useSignIn()` and `setActive()` for email/password sign-in +- Use `useAuth()` for sign-out and auth state gating + +## Environment variables + +Create `native-app/.env`: + +```env +EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx +EXPO_PUBLIC_HELIUM_BASE_URL=https://helium.srizan.dev +``` + +## Install + +```bash +pnpm -C native-app install +``` + +## Run on Android + +`react-native-webrtc` requires native modules, so use a development build: + +```bash +pnpm -C native-app prebuild +pnpm -C native-app android +``` + +## Host signaling protocol wired + +Implemented in `native-app/src/hooks/useHeliumStreamer.ts`: + +- send `create-room` +- receive `viewer-joined` +- create peer connection with selected preset `iceServers` +- send `offer` for each viewer +- receive `answer` +- exchange `ice-candidate` +- handle `viewer-left` +- heartbeat with `ping` every 15s diff --git a/native-app/app.json b/native-app/app.json new file mode 100644 index 0000000..09f07f1 --- /dev/null +++ b/native-app/app.json @@ -0,0 +1,44 @@ +{ + "expo": { + "name": "Helium Native", + "slug": "helium", + "scheme": "heliumnative", + "version": "0.1.0", + "icon": "./assets/images/icon.png", + "orientation": "portrait", + "userInterfaceStyle": "automatic", + "android": { + "package": "dev.srizan.helium.viewer", + "adaptiveIcon": { + "foregroundImage": "./assets/images/adaptive-icon.png", + "backgroundColor": "#c026d3", + "monochromeImage": "./assets/images/adaptive-icon-monochrome.png" + } + }, + "plugins": [ + "expo-secure-store", + "./plugins/withWebRTCMediaProjection", + [ + "expo-splash-screen", + { + "backgroundColor": "#fdfbff", + "image": "./assets/images/splash-icon.png", + "imageWidth": 220, + "dark": { + "backgroundColor": "#1e1b2e", + "image": "./assets/images/splash-icon-dark.png" + } + } + ] + ], + "ios": { + "bundleIdentifier": "dev.srizan.helium.viewer" + }, + "extra": { + "eas": { + "projectId": "abfcbaaf-3aed-4919-a172-429faeae8ac3" + } + }, + "owner": "srizan10" + } +} diff --git a/native-app/assets/images/adaptive-icon-monochrome.png b/native-app/assets/images/adaptive-icon-monochrome.png new file mode 100644 index 0000000..beb3b17 Binary files /dev/null and b/native-app/assets/images/adaptive-icon-monochrome.png differ diff --git a/native-app/assets/images/adaptive-icon.png b/native-app/assets/images/adaptive-icon.png new file mode 100644 index 0000000..beb3b17 Binary files /dev/null and b/native-app/assets/images/adaptive-icon.png differ diff --git a/native-app/assets/images/icon.png b/native-app/assets/images/icon.png new file mode 100644 index 0000000..8860617 Binary files /dev/null and b/native-app/assets/images/icon.png differ diff --git a/native-app/assets/images/logo-brand.svg b/native-app/assets/images/logo-brand.svg new file mode 100644 index 0000000..09fcb77 --- /dev/null +++ b/native-app/assets/images/logo-brand.svg @@ -0,0 +1,18 @@ + + + + diff --git a/native-app/assets/images/logo-white.svg b/native-app/assets/images/logo-white.svg new file mode 100644 index 0000000..d0b06e5 --- /dev/null +++ b/native-app/assets/images/logo-white.svg @@ -0,0 +1,18 @@ + + + + diff --git a/native-app/assets/images/splash-icon-dark.png b/native-app/assets/images/splash-icon-dark.png new file mode 100644 index 0000000..0ec9652 Binary files /dev/null and b/native-app/assets/images/splash-icon-dark.png differ diff --git a/native-app/assets/images/splash-icon.png b/native-app/assets/images/splash-icon.png new file mode 100644 index 0000000..c143d57 Binary files /dev/null and b/native-app/assets/images/splash-icon.png differ diff --git a/native-app/babel.config.js b/native-app/babel.config.js new file mode 100644 index 0000000..6ea4429 --- /dev/null +++ b/native-app/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function(api) { + api.cache(true); + + return { + presets: ["babel-preset-expo"], + }; +}; diff --git a/native-app/eas.json b/native-app/eas.json new file mode 100644 index 0000000..9d03dac --- /dev/null +++ b/native-app/eas.json @@ -0,0 +1,31 @@ +{ + "cli": { + "version": ">= 18.0.1", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "environment": "development" + }, + "preview": { + "distribution": "internal", + "developmentClient": true, + "environment": "preview" + }, + "apk": { + "environment": "production", + "android": { + "buildType": "apk" + } + }, + "production": { + "autoIncrement": true, + "environment": "production" + } + }, + "submit": { + "production": {} + } +} diff --git a/native-app/index.js b/native-app/index.js new file mode 100644 index 0000000..e5802d2 --- /dev/null +++ b/native-app/index.js @@ -0,0 +1,5 @@ +import { registerRootComponent } from "expo"; + +import App from "./App"; + +registerRootComponent(App); diff --git a/native-app/package.json b/native-app/package.json new file mode 100644 index 0000000..b68bb39 --- /dev/null +++ b/native-app/package.json @@ -0,0 +1,29 @@ +{ + "name": "helium-native", + "version": "0.1.0", + "private": true, + "main": "index.js", + "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "prebuild": "expo prebuild", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clerk/clerk-expo": "^2.19.22", + "expo": "^54.0.0", + "expo-secure-store": "^15.0.0", + "expo-splash-screen": "~31.0.13", + "react": "19.2.0", + "react-native": "0.82.0", + "react-native-safe-area-context": "^5.7.0", + "react-native-url-polyfill": "^2.0.0", + "react-native-webrtc": "^124.0.7" + }, + "devDependencies": { + "@types/react": "^19.2.2", + "babel-preset-expo": "^54.0.10", + "typescript": "^5.9.3" + } +} diff --git a/native-app/plugins/withWebRTCMediaProjection.js b/native-app/plugins/withWebRTCMediaProjection.js new file mode 100644 index 0000000..3dd0ab7 --- /dev/null +++ b/native-app/plugins/withWebRTCMediaProjection.js @@ -0,0 +1,58 @@ +const { + AndroidConfig, + createRunOncePlugin, + withMainActivity, +} = require("expo/config-plugins"); + +const PERMISSIONS = [ + "android.permission.FOREGROUND_SERVICE", + "android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION", +]; + +function withMediaProjectionPermissions(config) { + return AndroidConfig.Permissions.withPermissions(config, PERMISSIONS); +} + +function withMediaProjectionMainActivity(config) { + return withMainActivity(config, (configWithActivity) => { + const { modResults } = configWithActivity; + const importLine = + modResults.language === "kt" + ? "import com.oney.WebRTCModule.WebRTCModuleOptions" + : "import com.oney.WebRTCModule.WebRTCModuleOptions;"; + + const enableLine = + modResults.language === "kt" + ? " WebRTCModuleOptions.getInstance().enableMediaProjectionService = true" + : " WebRTCModuleOptions.getInstance().enableMediaProjectionService = true;"; + + if (!modResults.contents.includes(importLine)) { + modResults.contents = modResults.contents.replace( + /import android\.os\.Bundle\n/, + `import android.os.Bundle\n${importLine}\n`, + ); + } + + if (!modResults.contents.includes("enableMediaProjectionService = true")) { + modResults.contents = modResults.contents.replace( + /\s*super\.onCreate\(null\)/, + `\n${enableLine}\n super.onCreate(null)`, + ); + } + + configWithActivity.modResults = modResults; + return configWithActivity; + }); +} + +function withWebRTCMediaProjection(config) { + config = withMediaProjectionPermissions(config); + config = withMediaProjectionMainActivity(config); + return config; +} + +module.exports = createRunOncePlugin( + withWebRTCMediaProjection, + "with-webrtc-media-projection", + "1.0.0", +); diff --git a/native-app/src/components/ui/Button.tsx b/native-app/src/components/ui/Button.tsx new file mode 100644 index 0000000..6f8f93c --- /dev/null +++ b/native-app/src/components/ui/Button.tsx @@ -0,0 +1,138 @@ +import React from "react"; +import { + ActivityIndicator, + Pressable, + PressableProps, + StyleSheet, + Text, + View, +} from "react-native"; + +import { useAppTheme } from "../../lib/theme"; + +interface ButtonProps extends PressableProps { + variant?: "default" | "secondary" | "destructive" | "outline" | "ghost"; + size?: "default" | "sm" | "lg" | "icon"; + label?: string; + loading?: boolean; + children?: React.ReactNode; +} + +export function Button({ + variant = "default", + size = "default", + label, + loading = false, + children, + style, + disabled, + ...props +}: ButtonProps) { + const theme = useAppTheme(); + + const getBackgroundColor = (pressed: boolean) => { + if (disabled) return theme.muted; + switch (variant) { + case "default": + return theme.primary; + case "destructive": + return theme.destructive; + case "secondary": + return theme.secondary; + case "outline": + case "ghost": + return pressed ? theme.accent : "transparent"; + default: + return theme.primary; + } + }; + + const getBorderColor = () => { + if (variant === "outline") return theme.input; + return "transparent"; + }; + + const getTextColor = () => { + if (disabled) return theme.mutedForeground; + switch (variant) { + case "default": + return theme.primaryForeground; + case "destructive": + return theme.destructiveForeground; + case "secondary": + return theme.secondaryForeground; + case "outline": + case "ghost": + return theme.foreground; + default: + return theme.primaryForeground; + } + }; + + const getPadding = () => { + switch (size) { + case "sm": + return { paddingVertical: 8, paddingHorizontal: 12 }; + case "lg": + return { paddingVertical: 12, paddingHorizontal: 32 }; + case "icon": + return { padding: 10, width: 40, height: 40, justifyContent: "center", alignItems: "center" } as const; + default: + return { paddingVertical: 10, paddingHorizontal: 16 }; + } + }; + + return ( + [ + styles.base, + { + backgroundColor: getBackgroundColor(pressed), + borderColor: getBorderColor(), + borderWidth: variant === "outline" ? 1 : 0, + opacity: pressed && variant !== "outline" && variant !== "ghost" ? 0.9 : 1, + }, + getPadding(), + style as any, + ]} + {...props} + > + {loading ? ( + + ) : ( + + {children ? ( + children + ) : ( + + {label} + + )} + + )} + + ); +} + +const styles = StyleSheet.create({ + base: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + borderRadius: 8, // radius-md + }, + contentContainer: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + text: { + fontWeight: "600", + }, +}); diff --git a/native-app/src/components/ui/Card.tsx b/native-app/src/components/ui/Card.tsx new file mode 100644 index 0000000..8ec80ae --- /dev/null +++ b/native-app/src/components/ui/Card.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { StyleSheet, Text, TextProps, View, ViewProps } from "react-native"; + +import { useAppTheme } from "../../lib/theme"; + +export function Card({ style, ...props }: ViewProps) { + const theme = useAppTheme(); + return ( + + ); +} + +export function CardHeader({ style, ...props }: ViewProps) { + return ; +} + +export function CardTitle({ style, ...props }: TextProps) { + const theme = useAppTheme(); + return ( + + ); +} + +export function CardDescription({ style, ...props }: TextProps) { + const theme = useAppTheme(); + return ( + + ); +} + +export function CardContent({ style, ...props }: ViewProps) { + return ; +} + +export function CardFooter({ style, ...props }: ViewProps) { + return ; +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 12, // radius-xl + borderWidth: 1, + overflow: "hidden", + }, + header: { + padding: 18, + gap: 4, + }, + title: { + fontSize: 20, + fontWeight: "700", + letterSpacing: -0.5, + }, + description: { + fontSize: 14, + }, + content: { + paddingHorizontal: 18, + paddingBottom: 18, + }, + footer: { + padding: 18, + paddingTop: 0, + flexDirection: "row", + alignItems: "center", + }, +}); diff --git a/native-app/src/components/ui/Input.tsx b/native-app/src/components/ui/Input.tsx new file mode 100644 index 0000000..83345f7 --- /dev/null +++ b/native-app/src/components/ui/Input.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { StyleSheet, TextInput, TextInputProps, View, Text } from "react-native"; + +import { useAppTheme } from "../../lib/theme"; + +interface InputProps extends TextInputProps { + label?: string; + error?: string; +} + +export function Input({ label, error, style, ...props }: InputProps) { + const theme = useAppTheme(); + + return ( + + {label && {label}} + + {error && {error}} + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: 8, + marginBottom: 16, + }, + label: { + fontSize: 14, + fontWeight: "500", + }, + input: { + height: 40, // h-10 + borderWidth: 1, + borderRadius: 8, // radius-md + paddingHorizontal: 12, + fontSize: 14, + }, + error: { + fontSize: 12, + marginTop: 4, + }, +}); diff --git a/native-app/src/components/ui/index.ts b/native-app/src/components/ui/index.ts new file mode 100644 index 0000000..000b8ef --- /dev/null +++ b/native-app/src/components/ui/index.ts @@ -0,0 +1,3 @@ +export * from "./Button"; +export * from "./Card"; +export * from "./Input"; diff --git a/native-app/src/hooks/useHeliumStreamer.ts b/native-app/src/hooks/useHeliumStreamer.ts new file mode 100644 index 0000000..8b77294 --- /dev/null +++ b/native-app/src/hooks/useHeliumStreamer.ts @@ -0,0 +1,350 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + MediaStream, + RTCPeerConnection, + RTCIceCandidate, + RTCSessionDescription, + mediaDevices, +} from "react-native-webrtc"; + +import { getSignalingUrl } from "../lib/signaling"; +import type { MessageKey } from "../i18n/messages"; +import type { + IncomingSignalingMessage, + NativeIceServer, + NativeIceCandidateInit, + NativeSessionDescriptionInit, +} from "../types/signaling"; + +interface PeerConnectionHandlers { + onicecandidate: + | ((event: { candidate: RTCIceCandidate | null }) => void) + | null; + onconnectionstatechange: (() => void) | null; +} + +interface UseHeliumStreamerResult { + statusKey: MessageKey; + statusParams?: Record; + roomCode: string; + streamUrl: string | null; + viewerCount: number; + isSharing: boolean; + startSharing: () => Promise; + stopSharing: () => void; +} + +async function applyLowLatencyEncoding( + sender: ReturnType, +): Promise { + const parameters = sender.getParameters(); + + if (!parameters.encodings || parameters.encodings.length === 0) { + return; + } + + parameters.degradationPreference = "maintain-framerate"; + + const [firstEncoding] = parameters.encodings; + firstEncoding.maxBitrate = 1_200_000; + firstEncoding.maxFramerate = 24; + firstEncoding.scaleResolutionDownBy = 2; + + await sender.setParameters(parameters); +} + +function serializeIceCandidate(candidate: RTCIceCandidate): NativeIceCandidateInit { + const raw = candidate as unknown as { + candidate?: string; + sdpMid?: string | null; + sdpMLineIndex?: number | null; + toJSON?: () => NativeIceCandidateInit; + }; + + if (typeof raw.toJSON === "function") { + return raw.toJSON(); + } + + return { + candidate: raw.candidate ?? "", + sdpMid: raw.sdpMid ?? null, + sdpMLineIndex: raw.sdpMLineIndex ?? null, + }; +} + +export function useHeliumStreamer( + iceServers: NativeIceServer[], +): UseHeliumStreamerResult { + const wsRef = useRef(null); + const streamRef = useRef(null); + const heartbeatRef = useRef | null>(null); + const peersRef = useRef>({}); + + 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); + 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); + setStatusKey("statusStopped"); + setStatusParams(undefined); + }, [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) => { + const sender = peer.addTrack(track, localStream); + + if (track.kind === "video") { + void applyLowLatencyEncoding(sender); + } + }); + + peerWithHandlers.onicecandidate = (event): void => { + if (!event.candidate) { + return; + } + + const candidate = serializeIceCandidate(event.candidate); + if (!candidate.candidate) { + return; + } + + sendMessage({ + event: "ice-candidate", + targetId: viewerId, + candidate, + }); + }; + + peerWithHandlers.onconnectionstatechange = (): void => { + setStatusKey("statusPeerState"); + setStatusParams({ state: 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); + setStatusKey("statusRoomCreated"); + setStatusParams({ roomId: message.roomId }); + return; + } + + if (message.event === "viewer-joined") { + setStatusKey("statusViewerJoined"); + setStatusParams(undefined); + 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") { + setStatusKey("statusError"); + setStatusParams({ message: message.message }); + } + }, + [handleViewerJoined], + ); + + const startSharing = useCallback(async (): Promise => { + stopSharing(); + + if (!iceServers.length) { + setStatusKey("statusNoPreset"); + setStatusParams(undefined); + return; + } + + setStatusKey("statusRequestingCapture"); + setStatusParams(undefined); + const stream = await (mediaDevices as unknown as { + getDisplayMedia: (constraints?: { + video?: boolean; + audio?: boolean; + }) => Promise; + }).getDisplayMedia({ + video: true, + audio: true, + }); + streamRef.current = stream; + setStreamUrl(stream.toURL()); + setIsSharing(true); + + const videoTrackCount = stream.getVideoTracks().length; + const audioTrackCount = stream.getAudioTracks().length; + + if (!videoTrackCount) { + setStatusKey("statusNoVideoTrack"); + setStatusParams(undefined); + stopSharing(); + return; + } + + setStatusKey("statusCapturing"); + setStatusParams({ video: videoTrackCount, audio: audioTrackCount }); + + stream.getTracks().forEach((track) => { + const streamTrack = track as unknown as MediaStreamTrack & { + onended: (() => void) | null; + }; + streamTrack.onended = () => { + stopSharing(); + }; + }); + + setStatusKey("statusConnectingSignaling"); + setStatusParams(undefined); + + const ws = new WebSocket(getSignalingUrl()); + wsRef.current = ws; + + ws.onopen = (): void => { + setStatusKey("statusCreatingRoom"); + setStatusParams(undefined); + sendMessage({ event: "create-room" }); + + heartbeatRef.current = setInterval(() => { + sendMessage({ event: "ping" }); + }, 15000); + }; + + ws.onmessage = (message): void => { + void handleIncomingMessage(message); + }; + + ws.onerror = (): void => { + setStatusKey("statusWebsocketError"); + setStatusParams(undefined); + }; + + ws.onclose = (): void => { + if (heartbeatRef.current) { + clearInterval(heartbeatRef.current); + heartbeatRef.current = null; + } + if (isSharing) { + setStatusKey("statusWebsocketClosed"); + setStatusParams(undefined); + } + }; + }, [handleIncomingMessage, iceServers, isSharing, sendMessage, stopSharing]); + + useEffect(() => { + return () => { + stopSharing(); + }; + }, [stopSharing]); + + return { + statusKey, + statusParams, + roomCode, + streamUrl, + viewerCount, + isSharing, + startSharing, + stopSharing, + }; +} 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..fff48cd --- /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 (Android)", + signInSubtitle: "Inicia sesión con Clerk", + email: "Correo", + password: "Contraseña", + signIn: "Iniciar sesión", + signingIn: "Iniciando sesión...", + signedIn: "Sesión iniciada", + signInFailed: "Error al iniciar sesión", + needsExtraStep: "Falta un paso adicional: {status}", + loadingPresets: "Cargando ajustes predefinidos", + couldNotReadToken: "No se pudo leer el token", + noPresetsFound: "No se encontraron ajustes predefinidos", + loadedIceServers: "Se cargaron {count} entradas ICE", + failedToLoadPresets: "Error al cargar ajustes predefinidos: {message}", + failedToParsePreset: "Error al parsear ICE del preset", + streamerTitle: "Helium Emisor", + streamerSubtitle: "Comparte la pantalla de Android con Helium", + preset: "Preset", + session: "Sesión", + status: "Estado", + viewers: "Espectadores", + defaultLabel: "predeterminado", + startShare: "Iniciar pantalla", + stop: "Detener", + signOut: "Cerrar sesión", + 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 ajuste predefinido seleccionado", + statusRequestingCapture: "Solicitando captura de pantalla", + statusNoVideoTrack: "La captura inicio sin pista de video", + statusCapturing: "Capturando {video} video / {audio} audio", + statusConnectingSignaling: "Conectando señalización", + statusCreatingRoom: "Creando sala", + statusRoomCreated: "Código de sala: {roomId}", + statusViewerJoined: "Se unió un espectador", + statusPeerState: "Estado del peer: {state}", + statusWebsocketError: "Error de WebSocket", + statusWebsocketClosed: "WebSocket cerrado", + statusError: "Error: {message}", + }, +}; 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/signaling.ts b/native-app/src/lib/signaling.ts new file mode 100644 index 0000000..00688ea --- /dev/null +++ b/native-app/src/lib/signaling.ts @@ -0,0 +1,14 @@ +const DEFAULT_BASE_URL = "https://helium.srizan.dev"; + +export function getHeliumBaseUrl(): string { + return process.env.EXPO_PUBLIC_HELIUM_BASE_URL ?? DEFAULT_BASE_URL; +} + +export function getSignalingUrl(baseUrl: string = getHeliumBaseUrl()): string { + const url = new URL(baseUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.pathname = "/ws/signaling"; + url.search = ""; + url.hash = ""; + return url.toString(); +} diff --git a/native-app/src/lib/theme.ts b/native-app/src/lib/theme.ts new file mode 100644 index 0000000..43d6203 --- /dev/null +++ b/native-app/src/lib/theme.ts @@ -0,0 +1,75 @@ +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; + destructiveForeground: string; +} + +const lightTheme: AppTheme = { + background: "#FAFAFA", // slightly off-white + foreground: "#09090b", // zinc-950 + card: "#FFFFFF", + border: "#E4E4E7", // zinc-200 + input: "#E4E4E7", + muted: "#F4F4F5", // zinc-100 + mutedForeground: "#71717A", // zinc-500 + primary: "#c026d3", // fuchsia-600 + primaryForeground: "#FFFFFF", + secondary: "#F4F4F5", // zinc-100 + secondaryForeground: "#18181B", // zinc-900 + accent: "#F4F4F5", + destructive: "#EF4444", // red-500 + destructiveForeground: "#FFFFFF", +}; + +const darkTheme: AppTheme = { + background: "#18181b", // zinc-950 (or slight purple tint per brand: #1a1625) + foreground: "#FAFAFA", // zinc-50 + card: "#18181b", // Matches background often in shadcn default, or slightly lighter + border: "#27272A", // zinc-800 + input: "#27272A", + muted: "#27272A", + mutedForeground: "#A1A1AA", // zinc-400 + primary: "#d946ef", // fuchsia-500 + primaryForeground: "#1a1625", + secondary: "#27272A", + secondaryForeground: "#FAFAFA", + accent: "#27272A", + destructive: "#7F1D1D", // red-900 + destructiveForeground: "#FFFFFF", +}; + +// Override with specific brand colors from oklch analysis if needed +const brandLightTheme = { + ...lightTheme, + background: "#fdfbff", // slightly purple white + primary: "#c026d3", + secondary: "#f5f3ff", // light violet +}; + +const brandDarkTheme = { + ...darkTheme, + background: "#1e1b2e", // Deep purple/slate + card: "#1e1b2e", + border: "#2e2a45", + input: "#2e2a45", + muted: "#2e2a45", + primary: "#e879f9", // bright fuchsia +}; + +export function useAppTheme(): AppTheme { + const colorScheme = useColorScheme(); + return colorScheme === "dark" ? brandDarkTheme : brandLightTheme; +} diff --git a/native-app/src/screens/SignInScreen.tsx b/native-app/src/screens/SignInScreen.tsx new file mode 100644 index 0000000..6736f94 --- /dev/null +++ b/native-app/src/screens/SignInScreen.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { useSignIn } from "@clerk/clerk-expo"; +import { + KeyboardAvoidingView, + Platform, + StyleSheet, + Text, + View, +} from "react-native"; + +import { useAppTheme } from "../lib/theme"; +import { useI18n } from "../i18n/I18nProvider"; +import { Button } from "../components/ui/Button"; +import { Input } from "../components/ui/Input"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/Card"; + +export function SignInScreen() { + const theme = useAppTheme(); + const { t } = useI18n(); + 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) { + return; + } + + setStatus(t("signingIn")); + + try { + const attempt = await signIn.create({ + identifier: email.trim(), + password, + }); + + if (attempt.status === "complete") { + await setActive({ session: attempt.createdSessionId }); + setStatus(t("signedIn")); + return; + } + + setStatus(t("needsExtraStep", { status: String(attempt.status) })); + } catch { + setStatus(t("signInFailed")); + } + }; + + return ( + + + + + {t("appTitle")} + + {t("signInSubtitle")} + + + + + + + + + + ); + })} + + + + + + {t("session")} + + {t("status")}: {t(statusKey, statusParams)} • {t("viewers")}: {viewerCount} + + + + + {roomCode || "------"} + + + +