26 Commits

Author SHA1 Message Date
ImgBotApp
1a4f4e8aa0 [ImgBot] Optimize images
*Total -- 391.49kb -> 351.99kb (10.09%)

/native-app/assets/images/splash-icon-dark.png -- 51.29kb -> 17.55kb (65.77%)
/native-app/assets/images/logo-white.svg -- 0.59kb -> 0.53kb (10.4%)
/native-app/assets/images/logo-brand.svg -- 0.59kb -> 0.53kb (9.9%)
/app/assets/logo.svg -- 0.60kb -> 0.54kb (9.82%)
/native-app/assets/images/icon.png -- 1.17kb -> 1.08kb (7.68%)
/native-app/assets/images/adaptive-icon.png -- 28.69kb -> 28.03kb (2.29%)
/native-app/assets/images/adaptive-icon-monochrome.png -- 28.69kb -> 28.03kb (2.29%)
/build/icon.png -- 139.01kb -> 136.80kb (1.59%)
/native-app/assets/images/splash-icon.png -- 140.86kb -> 138.89kb (1.4%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2026-04-23 20:52:56 +00:00
1d926ce082 Merge pull request #3 from SrIzan10/feat/android-app
feat/android app
2026-04-23 22:52:13 +02:00
8fdb06641f feat: dls tag 2026-04-23 22:51:47 +02:00
2852cd56d8 ci: android workflow 2026-04-23 22:15:07 +02:00
9dd24d77fc fix: android app corners and new downloads page 2026-04-22 20:31:46 +02:00
da3d3b607e feat: apk builds 2026-03-19 21:27:13 +01:00
98b17af7be fix: build issues 2026-02-13 23:47:18 +01:00
3add1c2825 chore: some webrtc build fixes 2026-02-13 23:46:09 +01:00
55d8a6f173 chore: config changes 2026-02-13 23:22:32 +01:00
5454339d46 feat: add splash 2026-02-13 23:17:58 +01:00
c3cc78794f feat(native): add English and Spanish i18n support 2026-02-13 19:04:30 +01:00
79b29c3959 perf(native): reduce Android screen-share latency 2026-02-13 18:57:12 +01:00
e5bc2ec353 fix(native): enable Android media projection foreground service 2026-02-13 18:54:08 +01:00
e53b46def1 fix(native): serialize ICE candidates from Android peer 2026-02-13 18:49:54 +01:00
5538554f7a fix(native): stop preset detail request loop 2026-02-13 18:42:48 +01:00
38a557bd79 feat(native): add Android screen-share host with presets 2026-02-13 18:33:25 +01:00
b088b65dad fix(native): use explicit Expo entry file 2026-02-13 18:19:06 +01:00
c4f6fb87a1 fix(native): avoid Java keyword in app package id 2026-02-13 18:11:00 +01:00
0d7116050c feat(native): add React Native viewer with Clerk auth 2026-02-13 17:53:38 +01:00
b6909b8f49 fix(mobile): prefer Chrome launch on Android 2026-02-13 17:42:02 +01:00
1ee47a6940 fix(mobile): align react and react-dom versions 2026-02-13 17:39:13 +01:00
f6c836e705 fix(mobile): use relative asset paths for Capacitor 2026-02-13 17:36:51 +01:00
397fa9c0c4 feat(mobile): add Capacitor Android wrapper for Helium 2026-02-13 17:13:25 +01:00
61458b2759 fix: macos drag region 2026-02-10 20:14:42 +01:00
84a9f6f8bd fix: macos screen capture prompt 2026-02-10 19:40:42 +01:00
d349b226aa chore: regenerate icons 2026-02-10 19:06:59 +01:00
50 changed files with 8223 additions and 87 deletions

77
.github/workflows/android-release.yml vendored Normal file
View File

@@ -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 }}

10
.gitignore vendored
View File

@@ -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

View File

@@ -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

3
app.json Normal file
View File

@@ -0,0 +1,3 @@
{
"expo": {}
}

View File

@@ -1,18 +1 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 176 174"
fill="currentColor"
role="img"
aria-label="Logo">
<path fill-rule="evenodd" d="
M47.0,154.5 L8.5,153.0 L8.5,105.0 L28.0,90.5 L29.0,95.5 L68.0,95.5 L107.5,66.0
L107.5,31.0 L110.0,28.5 L128.0,15.5 L166.0,15.5 L167.5,65.0 L148.0,79.5
L147.0,74.5 L105.0,76.5 L68.5,104.0 L68.5,139.0 Z
M29.0,89.5 L28.5,46.0 L32.0,42.5 L69.0,15.5 L107.5,16.0 L107.5,30.0
L87.5,46.0 L87.0,74.5 L49.0,74.5 Z
M107.0,154.5 L68.5,154.0 L68.5,140.0 L88.5,124.0 L88.5,96.0 L127.0,95.5
L147.0,80.5 L147.5,124.0 Z
"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" aria-label="Logo" role="img" viewBox="0 0 176 174"><path fill-rule="evenodd" d="M47.0,154.5 L8.5,153.0 L8.5,105.0 L28.0,90.5 L29.0,95.5 L68.0,95.5 L107.5,66.0 L107.5,31.0 L110.0,28.5 L128.0,15.5 L166.0,15.5 L167.5,65.0 L148.0,79.5 L147.0,74.5 L105.0,76.5 L68.5,104.0 L68.5,139.0 Z M29.0,89.5 L28.5,46.0 L32.0,42.5 L69.0,15.5 L107.5,16.0 L107.5,30.0 L87.5,46.0 L87.0,74.5 L49.0,74.5 Z M107.0,154.5 L68.5,154.0 L68.5,140.0 L88.5,124.0 L88.5,96.0 L127.0,95.5 L147.0,80.5 L147.5,124.0 Z"/></svg>

Before

Width:  |  Height:  |  Size: 611 B

After

Width:  |  Height:  |  Size: 551 B

View File

@@ -36,6 +36,7 @@ interface HeliumElectronAPI {
venmicLink: (options: VenmicLinkOptions) => Promise<boolean>;
venmicUnlink: () => Promise<boolean>;
checkScreenPermission: () => Promise<string>;
openScreenPermissionSettings: () => Promise<boolean>;
}
declare global {
@@ -212,6 +213,28 @@ export function useElectron() {
return platformInfo.value.supportsLoopbackAudio || platformInfo.value.supportsVenmic;
});
const getScreenPermissionStatus = async (): Promise<string> => {
if (!checkElectron()) return "granted";
try {
return await window.heliumElectron!.checkScreenPermission();
} catch (error) {
console.error("[useElectron] Failed to check screen permission:", error);
return "unknown";
}
};
const openScreenPermissionSettings = async (): Promise<boolean> => {
if (!checkElectron()) return false;
try {
return await window.heliumElectron!.openScreenPermissionSettings();
} catch (error) {
console.error("[useElectron] Failed to open screen permission settings:", error);
return false;
}
};
onMounted(() => {
checkElectron();
if (isElectron.value) {
@@ -246,9 +269,10 @@ export function useElectron() {
linkAllAudio,
linkAppAudio,
unlinkVenmicAudio,
getScreenPermissionStatus,
openScreenPermissionSettings,
startScreenShareWithAudio,
stopScreenShare,
};
}

View File

@@ -2,6 +2,7 @@
import SignInDialog from "~/components/app/SignInDialog.vue";
import ThemeDropdown from "~/components/ui/ThemeDropdown.vue";
import LanguageSwitcher from "~/components/app/LanguageSwitcher.vue";
import { useElectron } from "~/composables/useElectron";
import "vue-sonner/style.css";
import { Toaster } from "@/components/ui/sonner";
import {
@@ -16,19 +17,37 @@ import LogoSvg from "~/assets/logo.svg?component";
const { t } = useI18n();
const mobileMenuOpen = ref(false);
const { isElectron, platformInfo, getPlatformInfo } = useElectron();
const isMacElectron = computed(() => {
return isElectron.value && platformInfo.value?.isMac;
});
onMounted(async () => {
if (isElectron.value && !platformInfo.value) {
await getPlatformInfo();
}
});
const navLinks = [
{ to: "/", label: "home" },
{ to: "/stream", label: "stream" },
{ to: "/about", label: "about" },
{ to: "/downloads", label: "downloads" },
{ to: "/presets", label: "presets", requiresAuth: true },
];
</script>
<template>
<div>
<header class="flex justify-between items-center p-4">
<div class="flex items-center space-x-4 md:space-x-6">
<header
class="flex justify-between items-center p-4"
:class="isMacElectron ? 'pl-24 [-webkit-app-region:drag] select-none' : ''"
>
<div
class="flex items-center space-x-4 md:space-x-6"
:class="isMacElectron ? '[-webkit-app-region:no-drag]' : ''"
>
<NuxtLink
to="/"
class="inline-flex items-center gap-2 text-lg font-semibold leading-none hover:opacity-80 transition-opacity"
@@ -61,7 +80,10 @@ const navLinks = [
</nav>
</div>
<div class="hidden md:flex items-center space-x-4">
<div
class="hidden md:flex items-center space-x-4"
:class="isMacElectron ? '[-webkit-app-region:no-drag]' : ''"
>
<LanguageSwitcher />
<ThemeDropdown />
<ClientOnly>
@@ -74,7 +96,10 @@ const navLinks = [
</ClientOnly>
</div>
<div class="md:hidden">
<div
class="md:hidden"
:class="isMacElectron ? '[-webkit-app-region:no-drag]' : ''"
>
<Sheet v-model:open="mobileMenuOpen">
<SheetTrigger as-child>
<button

298
app/pages/downloads.vue Normal file
View File

@@ -0,0 +1,298 @@
<script setup lang="ts">
import type { Component } from "vue";
import {
Apple,
Download,
ExternalLink,
Info,
Laptop,
Monitor,
Smartphone,
} from "lucide-vue-next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardDescription,
CardTitle,
} from "@/components/ui/card";
definePageMeta({
layout: "default",
});
interface GitHubReleaseAsset {
name: string;
browser_download_url: string;
}
interface GitHubRelease {
html_url: string;
assets: GitHubReleaseAsset[];
}
interface PlatformDownload {
name: string;
icon: Component;
formats: string;
href: string;
}
const { t } = useI18n();
const repositoryUrl = "https://github.com/SrIzan10/helium";
const releasesUrl = `${repositoryUrl}/releases`;
const latestReleaseFallbackUrl = `${releasesUrl}/latest`;
const latestReleaseApiUrl =
"https://api.github.com/repos/SrIzan10/helium/releases/latest";
const androidSourceUrl = `${repositoryUrl}/tree/main/native-app`;
const { data: latestRelease } = useFetch<GitHubRelease>(latestReleaseApiUrl, {
key: "helium-latest-release",
server: false,
default: () => ({
html_url: latestReleaseFallbackUrl,
assets: [],
}),
});
const latestReleaseUrl = computed<string>(() => {
return latestRelease.value?.html_url ?? latestReleaseFallbackUrl;
});
const releaseAssets = computed<GitHubReleaseAsset[]>(() => {
return latestRelease.value?.assets ?? [];
});
function findReleaseAsset(
patterns: readonly RegExp[],
): GitHubReleaseAsset | undefined {
return releaseAssets.value.find((asset) => {
return patterns.some((pattern) => pattern.test(asset.name));
});
}
function getReleaseAssetUrl(
patterns: readonly RegExp[],
fallbackUrl?: string,
): string {
return (
findReleaseAsset(patterns)?.browser_download_url ??
fallbackUrl ??
latestReleaseUrl.value
);
}
const desktopPlatforms = computed<PlatformDownload[]>(() => {
return [
{
name: "Windows",
icon: Laptop,
formats: "NSIS, Portable",
href: getReleaseAssetUrl([/-Setup-.*\\.exe$/i, /\\.exe$/i]),
},
{
name: "macOS",
icon: Apple,
formats: "DMG, ZIP",
href: getReleaseAssetUrl([/\\.dmg$/i, /-mac\\.zip$/i]),
},
{
name: "Linux",
icon: Laptop,
formats: "AppImage",
href: getReleaseAssetUrl([/\\.AppImage$/i]),
},
];
});
const androidPlatform = computed<PlatformDownload>(() => {
return {
name: "Android",
icon: Smartphone,
formats: "APK",
href: getReleaseAssetUrl([/^helium-android-.*\\.apk$/i]),
};
});
</script>
<template>
<div class="container max-w-5xl mx-auto px-4 py-12">
<div class="text-center space-y-4 mb-12">
<h1 class="text-4xl font-bold tracking-tight">
{{ t("downloadHelium") }}
</h1>
<p class="text-muted-foreground text-lg max-w-2xl mx-auto">
{{ t("downloadHeliumDescription") }}
</p>
</div>
<div class="grid md:grid-cols-2 gap-8">
<Card class="relative h-full overflow-hidden">
<div
class="absolute top-0 right-0 p-3 opacity-10 pointer-events-none"
>
<Monitor class="w-32 h-32" />
</div>
<CardHeader>
<div class="flex items-center gap-3">
<div
class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10 text-primary"
>
<Monitor class="w-5 h-5" />
</div>
<div>
<CardTitle>{{ t("desktopApp") }}</CardTitle>
<CardDescription>
{{ t("desktopAppDescription") }}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent class="flex-1 space-y-4">
<div class="space-y-3">
<div
v-for="platform in desktopPlatforms"
:key="platform.name"
>
<a
:href="platform.href"
target="_blank"
rel="noopener noreferrer"
class="group flex items-center justify-between rounded-lg bg-muted/50 p-3 transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div class="flex items-center gap-3">
<component
:is="platform.icon"
class="w-4 h-4 text-muted-foreground transition-colors group-hover:text-foreground"
/>
<span class="text-sm font-medium">{{ platform.name }}</span>
</div>
<div class="flex items-center gap-2">
<Badge variant="secondary" class="text-xs">
{{ platform.formats }}
</Badge>
<Download
class="w-4 h-4 text-muted-foreground transition-colors group-hover:text-foreground"
/>
</div>
</a>
</div>
</div>
<div
class="flex items-start gap-2 text-xs text-muted-foreground bg-muted/30 p-3 rounded-lg"
>
<Info class="w-4 h-4 mt-0.5 shrink-0" />
<span>{{ t("desktopAppNote") }}</span>
</div>
</CardContent>
<CardFooter class="mt-auto flex-col gap-3 items-stretch">
<Button as-child class="w-full gap-2">
<a
:href="latestReleaseUrl"
target="_blank"
rel="noopener noreferrer"
>
<Download class="w-4 h-4" />
{{ t("downloadFromGitHub") }}
</a>
</Button>
<Button as-child variant="outline" class="w-full gap-2">
<a
:href="repositoryUrl"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink class="w-4 h-4" />
{{ t("viewSourceCode") }}
</a>
</Button>
</CardFooter>
</Card>
<Card class="relative h-full overflow-hidden">
<div
class="absolute top-0 right-0 p-3 opacity-10 pointer-events-none"
>
<Smartphone class="w-32 h-32" />
</div>
<CardHeader>
<div class="flex items-center gap-3">
<div
class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10 text-primary"
>
<Smartphone class="w-5 h-5" />
</div>
<div>
<CardTitle>{{ t("androidApp") }}</CardTitle>
<CardDescription>
{{ t("androidAppDescription") }}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent class="flex-1 space-y-4">
<div>
<a
:href="androidPlatform.href"
target="_blank"
rel="noopener noreferrer"
class="group flex items-center justify-between rounded-lg bg-muted/50 p-3 transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div class="flex items-center gap-3">
<component
:is="androidPlatform.icon"
class="w-4 h-4 text-muted-foreground transition-colors group-hover:text-foreground"
/>
<span class="text-sm font-medium">
{{ androidPlatform.name }}
</span>
</div>
<div class="flex items-center gap-2">
<Badge variant="secondary" class="text-xs">
{{ androidPlatform.formats }}
</Badge>
<Download
class="w-4 h-4 text-muted-foreground transition-colors group-hover:text-foreground"
/>
</div>
</a>
</div>
<div
class="flex items-start gap-2 text-xs text-muted-foreground bg-muted/30 p-3 rounded-lg"
>
<Info class="w-4 h-4 mt-0.5 shrink-0" />
<span>{{ t("androidAppNote") }}</span>
</div>
</CardContent>
<CardFooter class="mt-auto flex-col gap-3 items-stretch">
<Button as-child class="w-full gap-2">
<a
:href="androidPlatform.href"
target="_blank"
rel="noopener noreferrer"
>
<Download class="w-4 h-4" />
{{ t("downloadFromGitHub") }}
</a>
</Button>
<Button as-child variant="outline" class="w-full gap-2">
<a
:href="androidSourceUrl"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink class="w-4 h-4" />
{{ t("viewSourceCode") }}
</a>
</Button>
</CardFooter>
</Card>
</div>
</div>
</template>

View File

@@ -56,6 +56,7 @@
<script setup lang="ts">
import { useWebSocket } from "@vueuse/core";
import { toast } from "vue-sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
@@ -66,6 +67,7 @@ import { useElectron } from "~/composables/useElectron";
import PresetSelect from "~/components/app/PresetSelect.vue";
const streamerStore = useStreamerStore();
const { t } = useI18n();
const videofeedRef = ref<HTMLVideoElement | null>(null);
const localStream = ref<MediaStream | null>(null);
const wsUrl = useWebSocketUrl();
@@ -83,6 +85,8 @@ const {
linkAllAudio,
linkAppAudio,
unlinkVenmicAudio,
getScreenPermissionStatus,
openScreenPermissionSettings,
} = useElectron();
onMounted(async () => {
@@ -231,6 +235,7 @@ async function startScreenShare() {
);
} catch (error) {
console.error("Failed to start screen share:", error);
await handleScreenShareError(error);
cleanupStreaming();
}
}
@@ -293,9 +298,36 @@ async function changeScreenShareSource() {
}
} catch (error) {
console.error("Failed to change screen share source:", error);
await handleScreenShareError(error);
}
}
async function handleScreenShareError(error: unknown): Promise<void> {
const isPermissionDeniedError =
error instanceof DOMException && error.name === "NotAllowedError";
if (!isPermissionDeniedError || !isElectron.value || !platformInfo.value?.isMac) {
toast.error(t("failedToStartScreenShare"));
return;
}
const permissionStatus = await getScreenPermissionStatus();
if (permissionStatus === "granted") {
toast.error(t("failedToStartScreenShare"));
return;
}
const openedSettings = await openScreenPermissionSettings();
if (openedSettings) {
toast.error(t("screenRecordingPermissionRequired"));
return;
}
toast.error(t("screenRecordingPermissionRequiredNoShortcut"));
}
async function cleanupStreaming() {
if (localStream.value) {
localStream.value.getTracks().forEach((track) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 137 KiB

21
eas.json Normal file
View File

@@ -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": {}
}
}

View File

@@ -4,6 +4,7 @@ import {
ipcMain,
desktopCapturer,
session,
shell,
systemPreferences,
type IpcMainInvokeEvent,
type DesktopCapturerSource,
@@ -208,6 +209,20 @@ ipcMain.handle('helium:check-screen-permission', () => {
return 'granted';
});
ipcMain.handle('helium:open-screen-permission-settings', async () => {
if (!isMac) {
return false;
}
try {
await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture');
return true;
} catch (error) {
console.error('[Helium] Failed to open macOS screen recording settings:', error);
return false;
}
});
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {

View File

@@ -57,6 +57,8 @@ const heliumElectronAPI = {
venmicUnlink: (): Promise<boolean> => ipcRenderer.invoke('helium:venmic-unlink'),
checkScreenPermission: (): Promise<string> => ipcRenderer.invoke('helium:check-screen-permission'),
openScreenPermissionSettings: (): Promise<boolean> =>
ipcRenderer.invoke('helium:open-screen-permission-settings'),
};
contextBridge.exposeInMainWorld('heliumElectron', heliumElectronAPI);

View File

@@ -5,6 +5,7 @@
"home": "Home",
"about": "About",
"contact": "Contact",
"downloads": "Downloads",
"stream": "Stream",
"presets": "Presets",
"effortlessScreensharing": "effortless screensharing powered by webrtc",
@@ -59,5 +60,21 @@
"audioSource": "Audio Source",
"allSystemAudio": "All System Audio",
"refreshSources": "Refresh Sources",
"audioSupported": "Audio Supported"
"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.",
"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"
}

View File

@@ -5,6 +5,7 @@
"home": "Inicio",
"about": "Acerca de",
"contact": "Contacto",
"downloads": "Descargas",
"stream": "Transmisión",
"presets": "Ajustes predefinidos",
"effortlessScreensharing": "comparte pantalla sin complicaciones",
@@ -59,5 +60,21 @@
"audioSource": "Fuente de audio",
"allSystemAudio": "Todo el audio del sistema",
"refreshSources": "Actualizar fuentes",
"audioSupported": "Audio soportado"
"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.",
"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"
}

5
native-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.expo
android
ios
dist

78
native-app/App.tsx Normal file
View File

@@ -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 (
<View style={[styles.loadingWrap, { backgroundColor: theme.background }]}>
<ActivityIndicator color={theme.primary} size="large" />
</View>
);
}
return (
<>
<SignedOut>
<SignInScreen />
</SignedOut>
<SignedIn>
<StreamerScreen />
</SignedIn>
</>
);
}
export default function App() {
return (
<I18nProvider>
<AppContent />
</I18nProvider>
);
}
function AppContent() {
const theme = useAppTheme();
const { t } = useI18n();
if (!publishableKey) {
return (
<View style={[styles.loadingWrap, { backgroundColor: theme.background }]}>
<Text style={[styles.errorText, { color: theme.destructive }]}>
{t("missingClerkKey")}
</Text>
</View>
);
}
return (
<ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
<AuthReadyGate />
</ClerkProvider>
);
}
const styles = StyleSheet.create({
loadingWrap: {
alignItems: "center",
flex: 1,
justifyContent: "center",
},
errorText: {
fontSize: 16,
paddingHorizontal: 20,
textAlign: "center",
},
});

58
native-app/README.md Normal file
View File

@@ -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

44
native-app/app.json Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#c026d3" aria-label="Logo" role="img" viewBox="0 0 176 174"><path fill-rule="evenodd" d="M47.0,154.5 L8.5,153.0 L8.5,105.0 L28.0,90.5 L29.0,95.5 L68.0,95.5 L107.5,66.0 L107.5,31.0 L110.0,28.5 L128.0,15.5 L166.0,15.5 L167.5,65.0 L148.0,79.5 L147.0,74.5 L105.0,76.5 L68.5,104.0 L68.5,139.0 Z M29.0,89.5 L28.5,46.0 L32.0,42.5 L69.0,15.5 L107.5,16.0 L107.5,30.0 L87.5,46.0 L87.0,74.5 L49.0,74.5 Z M107.0,154.5 L68.5,154.0 L68.5,140.0 L88.5,124.0 L88.5,96.0 L127.0,95.5 L147.0,80.5 L147.5,124.0 Z"/></svg>

After

Width:  |  Height:  |  Size: 546 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#fff" aria-label="Logo" role="img" viewBox="0 0 176 174"><path fill-rule="evenodd" d="M47.0,154.5 L8.5,153.0 L8.5,105.0 L28.0,90.5 L29.0,95.5 L68.0,95.5 L107.5,66.0 L107.5,31.0 L110.0,28.5 L128.0,15.5 L166.0,15.5 L167.5,65.0 L148.0,79.5 L147.0,74.5 L105.0,76.5 L68.5,104.0 L68.5,139.0 Z M29.0,89.5 L28.5,46.0 L32.0,42.5 L69.0,15.5 L107.5,16.0 L107.5,30.0 L87.5,46.0 L87.0,74.5 L49.0,74.5 Z M107.0,154.5 L68.5,154.0 L68.5,140.0 L88.5,124.0 L88.5,96.0 L127.0,95.5 L147.0,80.5 L147.5,124.0 Z"/></svg>

After

Width:  |  Height:  |  Size: 543 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View File

@@ -0,0 +1,7 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
};
};

31
native-app/eas.json Normal file
View File

@@ -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": {}
}
}

5
native-app/index.js Normal file
View File

@@ -0,0 +1,5 @@
import { registerRootComponent } from "expo";
import App from "./App";
registerRootComponent(App);

29
native-app/package.json Normal file
View File

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

View File

@@ -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",
);

View File

@@ -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 (
<Pressable
disabled={disabled || loading}
style={({ pressed }) => [
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 ? (
<ActivityIndicator color={getTextColor()} size="small" />
) : (
<View style={styles.contentContainer}>
{children ? (
children
) : (
<Text
style={[
styles.text,
{ color: getTextColor(), fontSize: size === "lg" ? 16 : 14 },
]}
>
{label}
</Text>
)}
</View>
)}
</Pressable>
);
}
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",
},
});

View File

@@ -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 (
<View
style={[
styles.card,
{
backgroundColor: theme.card,
borderColor: theme.border,
},
style,
]}
{...props}
/>
);
}
export function CardHeader({ style, ...props }: ViewProps) {
return <View style={[styles.header, style]} {...props} />;
}
export function CardTitle({ style, ...props }: TextProps) {
const theme = useAppTheme();
return (
<Text
style={[styles.title, { color: theme.foreground }, style]}
{...props}
/>
);
}
export function CardDescription({ style, ...props }: TextProps) {
const theme = useAppTheme();
return (
<Text
style={[
styles.description,
{ color: theme.mutedForeground },
style,
]}
{...props}
/>
);
}
export function CardContent({ style, ...props }: ViewProps) {
return <View style={[styles.content, style]} {...props} />;
}
export function CardFooter({ style, ...props }: ViewProps) {
return <View style={[styles.footer, style]} {...props} />;
}
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",
},
});

View File

@@ -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 (
<View style={styles.container}>
{label && <Text style={[styles.label, { color: theme.foreground }]}>{label}</Text>}
<TextInput
placeholderTextColor={theme.mutedForeground}
style={[
styles.input,
{
backgroundColor: "transparent",
borderColor: error ? theme.destructive : theme.border,
color: theme.foreground,
},
style,
]}
{...props}
/>
{error && <Text style={[styles.error, { color: theme.destructive }]}>{error}</Text>}
</View>
);
}
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,
},
});

View File

@@ -0,0 +1,3 @@
export * from "./Button";
export * from "./Card";
export * from "./Input";

View File

@@ -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<string, string | number>;
roomCode: string;
streamUrl: string | null;
viewerCount: number;
isSharing: boolean;
startSharing: () => Promise<void>;
stopSharing: () => void;
}
async function applyLowLatencyEncoding(
sender: ReturnType<RTCPeerConnection["addTrack"]>,
): Promise<void> {
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<WebSocket | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const peersRef = useRef<Record<string, RTCPeerConnection>>({});
const [statusKey, setStatusKey] = useState<MessageKey>("statusIdle");
const [statusParams, setStatusParams] = useState<
Record<string, string | number> | undefined
>(undefined);
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);
setStatusKey("statusStopped");
setStatusParams(undefined);
}, [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) => {
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<string>): Promise<void> => {
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<void> => {
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<MediaStream>;
}).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,
};
}

View File

@@ -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, string | number>) => string;
}
const I18nContext = createContext<I18nContextValue | null>(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, string | number>,
): 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<I18nContextValue>(() => {
return {
locale,
t: (key, params) => translate(locale, key, params),
};
}, [locale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const context = useContext(I18nContext);
if (!context) {
throw new Error("useI18n must be used within I18nProvider");
}
return context;
}

View File

@@ -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<MessageKey, string>;
export const messages: Record<Locale, MessageMap> = {
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}",
},
};

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,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();
}

View File

@@ -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;
}

View File

@@ -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<string>("");
const [password, setPassword] = useState<string>("");
const [status, setStatus] = useState<string>("");
const styles = createStyles(theme);
const onSignIn = async (): Promise<void> => {
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 (
<View style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={styles.keyboardView}
>
<Card style={styles.card}>
<CardHeader>
<CardTitle style={{ textAlign: "center" }}>{t("appTitle")}</CardTitle>
<CardDescription style={{ textAlign: "center" }}>
{t("signInSubtitle")}
</CardDescription>
</CardHeader>
<CardContent style={{ gap: 4 }}>
<Input
autoCapitalize="none"
keyboardType="email-address"
label={t("email")}
onChangeText={setEmail}
placeholder="name@example.com"
value={email}
/>
<Input
label={t("password")}
onChangeText={setPassword}
placeholder="••••••••"
secureTextEntry
value={password}
/>
<View style={{ height: 16 }} />
<Button
disabled={!isLoaded || !email || !password}
label={t("signIn")}
loading={!isLoaded}
onPress={() => {
void onSignIn();
}}
size="lg"
/>
{status ? (
<Text style={styles.status}>{status}</Text>
) : null}
</CardContent>
</Card>
</KeyboardAvoidingView>
</View>
);
}
function createStyles(theme: ReturnType<typeof useAppTheme>) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.background,
justifyContent: "center",
padding: 24,
},
keyboardView: {
justifyContent: "center",
flex: 1,
maxWidth: 500,
width: "100%",
alignSelf: "center",
},
card: {
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
},
status: {
color: theme.mutedForeground,
fontSize: 13,
textAlign: "center",
marginTop: 16,
},
});
}

View File

@@ -0,0 +1,249 @@
import { useAuth } from "@clerk/clerk-expo";
import { useEffect, useMemo, useState } from "react";
import {
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { useHeliumStreamer } from "../hooks/useHeliumStreamer";
import { useI18n } from "../i18n/I18nProvider";
import type { MessageKey } from "../i18n/messages";
import { useAppTheme } from "../lib/theme";
import { getPresets } from "../lib/presets";
import type { NativeIceServer, PresetUser } from "../types/presets";
import { Button } from "../components/ui/Button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../components/ui/Card";
import { SafeAreaView } from 'react-native-safe-area-context';
export function StreamerScreen() {
const { getToken, signOut } = useAuth();
const theme = useAppTheme();
const { t } = useI18n();
const [presets, setPresets] = useState<PresetUser[]>([]);
const [presetId, setPresetId] = useState<string>("");
const [iceServers, setIceServers] = useState<NativeIceServer[]>([]);
const [presetStatusKey, setPresetStatusKey] = useState<MessageKey>("loadingPresets");
const [presetStatusParams, setPresetStatusParams] = useState<
Record<string, string | number> | undefined
>(undefined);
const styles = useMemo(() => createStyles(theme), [theme]);
const {
statusKey,
statusParams,
roomCode,
viewerCount,
isSharing,
startSharing,
stopSharing,
} = useHeliumStreamer(iceServers);
const selectedPreset = useMemo(() => {
return presets.find((preset) => preset.presetId === presetId) ?? null;
}, [presetId, presets]);
useEffect(() => {
const loadPresets = async (): Promise<void> => {
const token = await getToken();
if (!token) {
setPresetStatusKey("couldNotReadToken");
setPresetStatusParams(undefined);
return;
}
try {
const availablePresets = await getPresets(token);
setPresets(availablePresets);
if (!availablePresets.length) {
setPresetStatusKey("noPresetsFound");
setPresetStatusParams(undefined);
return;
}
const defaultPreset =
availablePresets.find((preset) => preset.isDefault) ?? availablePresets[0];
setPresetId(defaultPreset.presetId);
} catch (error) {
setPresetStatusKey("failedToLoadPresets");
setPresetStatusParams({ message: (error as Error).message });
}
};
void loadPresets();
}, []);
useEffect(() => {
if (!selectedPreset) {
setIceServers([]);
return;
}
try {
const rawIceServers = selectedPreset.preset.iceServers;
const parsedIceServers =
typeof rawIceServers === "string"
? (JSON.parse(rawIceServers) as NativeIceServer[])
: rawIceServers;
setIceServers(parsedIceServers ?? []);
setPresetStatusKey("loadedIceServers");
setPresetStatusParams({ count: (parsedIceServers ?? []).length });
} catch {
setIceServers([]);
setPresetStatusKey("failedToParsePreset");
setPresetStatusParams(undefined);
}
}, [selectedPreset]);
return (
<SafeAreaView style={styles.safeArea}>
<ScrollView contentContainerStyle={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>{t("streamerTitle")}</Text>
<Text style={styles.subtitle}>{t("streamerSubtitle")}</Text>
</View>
<Card>
<CardHeader>
<CardTitle>{t("preset")}</CardTitle>
<CardDescription>{t(presetStatusKey, presetStatusParams)}</CardDescription>
</CardHeader>
<CardContent style={styles.presetList}>
{presets.map((preset) => {
const selected = presetId === preset.presetId;
return (
<Button
key={preset.presetId}
onPress={() => setPresetId(preset.presetId)}
variant={selected ? "secondary" : "ghost"}
style={{ justifyContent: "flex-start" }}
>
<Text style={{ color: selected ? theme.secondaryForeground : theme.foreground }}>
{preset.preset.name}
{preset.isDefault ? ` (${t("defaultLabel")})` : ""}
</Text>
</Button>
);
})}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("session")}</CardTitle>
<CardDescription>
{t("status")}: {t(statusKey, statusParams)} {t("viewers")}: {viewerCount}
</CardDescription>
</CardHeader>
<CardContent style={{ gap: 12 }}>
<View style={styles.roomCodeContainer}>
<Text style={styles.roomCode}>{roomCode || "------"}</Text>
</View>
<View style={styles.actions}>
<Button
onPress={() => void startSharing()}
label={t("startShare")}
variant="default"
style={{ flex: 1 }}
/>
<Button
onPress={stopSharing}
label={t("stop")}
variant="secondary"
/>
</View>
</CardContent>
</Card>
<View style={styles.preview}>
{isSharing ? (
<Text style={styles.previewPlaceholder}>
{t("previewActive")}
</Text>
) : (
<Text style={styles.previewPlaceholder}>{t("previewIdle")}</Text>
)}
</View>
<Button
onPress={() => void signOut()}
label={t("signOut")}
variant="destructive"
style={{ marginTop: 8 }}
/>
</ScrollView>
</SafeAreaView>
);
}
function createStyles(theme: ReturnType<typeof useAppTheme>) {
return StyleSheet.create({
safeArea: {
backgroundColor: theme.background,
flex: 1,
},
container: {
gap: 16,
padding: 16,
paddingBottom: 28,
},
header: {
marginBottom: 8,
},
title: {
color: theme.foreground,
fontSize: 28,
fontWeight: "800",
letterSpacing: -0.5,
},
subtitle: {
color: theme.mutedForeground,
fontSize: 16,
},
presetList: {
gap: 4,
},
roomCodeContainer: {
alignItems: "center",
paddingVertical: 12,
backgroundColor: theme.secondary,
borderRadius: 8,
marginBottom: 4,
},
roomCode: {
color: theme.primary,
fontSize: 32,
fontWeight: "800",
letterSpacing: 4,
fontVariant: ["tabular-nums"],
},
actions: {
flexDirection: "row",
gap: 8,
},
preview: {
alignItems: "center",
backgroundColor: "#000000",
borderRadius: 12,
height: 200,
justifyContent: "center",
overflow: "hidden",
borderWidth: 1,
borderColor: theme.border,
},
previewPlaceholder: {
color: theme.mutedForeground,
paddingHorizontal: 16,
textAlign: "center",
},
});
}

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

@@ -0,0 +1,76 @@
export interface NativeSessionDescriptionInit {
type: "offer" | "answer" | "pranswer" | "rollback";
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?: NativeIceServer[];
}
export interface SignalingIceCandidateEvent {
event: "ice-candidate";
from: string;
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 {
event: "joined";
roomId: string;
}
export interface SignalingErrorEvent {
event: "error";
message: string;
}
export interface SignalingRoomClosedEvent {
event: "room-closed";
}
export type IncomingSignalingMessage =
| SignalingOfferEvent
| SignalingIceCandidateEvent
| SignalingJoinedEvent
| SignalingErrorEvent
| SignalingRoomClosedEvent
| SignalingViewerJoinedEvent
| SignalingViewerLeftEvent
| SignalingRoomCreatedEvent
| SignalingAnswerEvent
| { event: "pong" };

8
native-app/tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"jsx": "react-jsx"
},
"include": ["App.tsx", "src/**/*.ts", "src/**/*.tsx"]
}

View File

@@ -11,7 +11,7 @@
"main": "./electron/dist/main/index.js",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"dev": "nuxt dev --host",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
@@ -26,7 +26,11 @@
"electron:publish": "pnpm electron:compile && electron-builder --publish always",
"electron:publish:win": "pnpm electron:compile && electron-builder --win --publish always",
"electron:publish:mac": "pnpm electron:compile && electron-builder --mac --publish always",
"electron:publish:linux": "pnpm electron:compile && electron-builder --linux --publish always"
"electron:publish:linux": "pnpm electron:compile && electron-builder --linux --publish always",
"native:install": "pnpm -C native-app install",
"native:android": "pnpm -C native-app android",
"native:start": "pnpm -C native-app start",
"native:typecheck": "pnpm -C native-app typecheck"
},
"dependencies": {
"@clerk/localizations": "^3.34.0",

5934
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,8 @@
packages:
- .
- mobile-wrapper
- native-app
onlyBuiltDependencies:
- '@vencord/venmic'
- electron