Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d926ce082 | |||
| 8fdb06641f | |||
| 2852cd56d8 | |||
| 9dd24d77fc | |||
| da3d3b607e | |||
| 98b17af7be | |||
| 3add1c2825 | |||
| 55d8a6f173 | |||
| 5454339d46 | |||
| c3cc78794f | |||
| 79b29c3959 | |||
| e5bc2ec353 | |||
| e53b46def1 | |||
| 5538554f7a | |||
| 38a557bd79 | |||
| b088b65dad | |||
| c4f6fb87a1 | |||
| 0d7116050c | |||
| b6909b8f49 | |||
| 1ee47a6940 | |||
| f6c836e705 | |||
| 397fa9c0c4 | |||
| 61458b2759 | |||
| 84a9f6f8bd | |||
| d349b226aa |
77
.github/workflows/android-release.yml
vendored
Normal 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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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) => {
|
||||
|
||||
BIN
build/icon.ico
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 110 KiB |
BIN
build/icon.png
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 139 KiB |
21
eas.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.expo
|
||||
android
|
||||
ios
|
||||
dist
|
||||
78
native-app/App.tsx
Normal 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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
BIN
native-app/assets/images/adaptive-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
native-app/assets/images/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
native-app/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
18
native-app/assets/images/logo-brand.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 176 174"
|
||||
fill="#c026d3"
|
||||
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>
|
||||
|
After Width: | Height: | Size: 606 B |
18
native-app/assets/images/logo-white.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 176 174"
|
||||
fill="#ffffff"
|
||||
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>
|
||||
|
After Width: | Height: | Size: 606 B |
BIN
native-app/assets/images/splash-icon-dark.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
native-app/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
7
native-app/babel.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
31
native-app/eas.json
Normal 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
@@ -0,0 +1,5 @@
|
||||
import { registerRootComponent } from "expo";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
registerRootComponent(App);
|
||||
29
native-app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
58
native-app/plugins/withWebRTCMediaProjection.js
Normal 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",
|
||||
);
|
||||
138
native-app/src/components/ui/Button.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
87
native-app/src/components/ui/Card.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
55
native-app/src/components/ui/Input.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
3
native-app/src/components/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./Button";
|
||||
export * from "./Card";
|
||||
export * from "./Input";
|
||||
350
native-app/src/hooks/useHeliumStreamer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
54
native-app/src/i18n/I18nProvider.tsx
Normal 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;
|
||||
}
|
||||
138
native-app/src/i18n/messages.ts
Normal 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}",
|
||||
},
|
||||
};
|
||||
53
native-app/src/lib/presets.ts
Normal 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 ?? [];
|
||||
}
|
||||
14
native-app/src/lib/signaling.ts
Normal 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();
|
||||
}
|
||||
75
native-app/src/lib/theme.ts
Normal 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;
|
||||
}
|
||||
135
native-app/src/screens/SignInScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
249
native-app/src/screens/StreamerScreen.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
33
native-app/src/types/presets.ts
Normal 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;
|
||||
}
|
||||
76
native-app/src/types/signaling.ts
Normal 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
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["App.tsx", "src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -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
@@ -1,3 +1,8 @@
|
||||
packages:
|
||||
- .
|
||||
- mobile-wrapper
|
||||
- native-app
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@vencord/venmic'
|
||||
- electron
|
||||
|
||||