4 Commits

Author SHA1 Message Date
21f7df22d5 chore: bump version 2026-06-13 17:03:57 +02:00
0744922d19 chore: dockerignore 2026-06-13 16:53:56 +02:00
a2414aee2f fix: builds 2026-06-13 16:41:10 +02:00
a90d7c8f8c refactor: streaming page and some other desktop app updats 2026-06-13 16:03:23 +02:00
24 changed files with 659 additions and 163 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.nuxt
.output
.data
node_modules
dist
dist-electron
native-app
coverage
*.log
.env
.env.*
!.env.example

View File

@@ -3,13 +3,13 @@
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
# Copy package.json and your lockfile, here we add pnpm-lock.yaml for illustration
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN pnpm i
RUN pnpm install --frozen-lockfile
# Copy the entire project
COPY . ./

View File

@@ -1 +1,18 @@
<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>
<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>

Before

Width:  |  Height:  |  Size: 551 B

After

Width:  |  Height:  |  Size: 611 B

View File

@@ -14,6 +14,7 @@ import { useStreamerStore } from "~/state/streamer";
const { t } = useI18n();
const router = useRouter();
const selectedValue = ref("");
const modelValue = defineModel<string>({ default: "" });
const presets = ref<PresetUser[]>([]);
const loading = ref(true);
const streamerStore = useStreamerStore();
@@ -27,6 +28,7 @@ onMounted(async () => {
const defaultPreset = presets.value.find((p) => p.isDefault);
if (defaultPreset) {
selectedValue.value = defaultPreset.presetId;
modelValue.value = defaultPreset.presetId;
// Load the default preset's ice servers
loadPresetIceServers(defaultPreset.presetId);
}
@@ -60,9 +62,19 @@ watch(selectedValue, (newValue) => {
if (newValue === "create-new") {
router.push("/presets/new");
selectedValue.value = "";
modelValue.value = "";
} else if (newValue) {
modelValue.value = newValue;
// Load ice servers for the selected preset
loadPresetIceServers(newValue);
} else {
modelValue.value = "";
}
});
watch(modelValue, (newValue) => {
if (newValue !== selectedValue.value) {
selectedValue.value = newValue;
}
});
</script>

View File

@@ -24,6 +24,13 @@ export interface VenmicLinkOptions {
only_default_speakers?: boolean;
}
export interface StreamingClosePromptOptions {
title: string;
message: string;
confirmLabel: string;
cancelLabel: string;
}
interface HeliumElectronAPI {
isElectron: boolean;
getPlatform: () => Promise<PlatformInfo>;
@@ -37,6 +44,10 @@ interface HeliumElectronAPI {
venmicUnlink: () => Promise<boolean>;
checkScreenPermission: () => Promise<string>;
openScreenPermissionSettings: () => Promise<boolean>;
setStreamingActive: (
active: boolean,
promptOptions?: StreamingClosePromptOptions,
) => Promise<boolean>;
}
declare global {
@@ -235,6 +246,23 @@ export function useElectron() {
}
};
const setStreamingActive = async (
active: boolean,
promptOptions?: StreamingClosePromptOptions,
): Promise<boolean> => {
if (!checkElectron()) return false;
try {
return await window.heliumElectron!.setStreamingActive(
active,
promptOptions,
);
} catch (error) {
console.error("[useElectron] Failed to set streaming status:", error);
return false;
}
};
onMounted(() => {
checkElectron();
if (isElectron.value) {
@@ -271,6 +299,7 @@ export function useElectron() {
unlinkVenmicAudio,
getScreenPermissionStatus,
openScreenPermissionSettings,
setStreamingActive,
startScreenShareWithAudio,
stopScreenShare,

View File

@@ -32,10 +32,14 @@ onMounted(async () => {
const navLinks = [
{ to: "/", label: "home" },
{ to: "/stream", label: "stream" },
{ to: "/about", label: "about" },
{ to: "/downloads", label: "downloads" },
{ to: "/about", label: "about", hideInElectron: true },
{ to: "/downloads", label: "downloads", hideInElectron: true },
{ to: "/presets", label: "presets", requiresAuth: true },
];
const visibleNavLinks = computed(() => {
return navLinks.filter((link) => !isElectron.value || !link.hideInElectron);
});
</script>
<template>
@@ -56,7 +60,7 @@ const navLinks = [
<span class="leading-none">helium</span>
</NuxtLink>
<nav class="hidden md:flex space-x-4">
<template v-for="link in navLinks" :key="link.to">
<template v-for="link in visibleNavLinks" :key="link.to">
<ClientOnly v-if="link.requiresAuth">
<SignedIn>
<NuxtLink
@@ -114,7 +118,7 @@ const navLinks = [
<SheetTitle>{{ t("menu") || "Menu" }}</SheetTitle>
</SheetHeader>
<nav class="flex flex-col space-y-4 mt-6">
<template v-for="link in navLinks" :key="link.to">
<template v-for="link in visibleNavLinks" :key="link.to">
<ClientOnly v-if="link.requiresAuth">
<SignedIn>
<NuxtLink

View File

@@ -1,5 +1,10 @@
<script setup lang="ts">
const { locale } = useI18n();
const clerkLocaleVersion = useState("clerk-locale-version", () => 0);
</script>
<template>
<div class="flex h-full w-full items-center justify-center">
<SignIn routing="path" path="/sign-in" />
<SignIn :key="`${locale}-${clerkLocaleVersion}`" routing="path" path="/sign-in" />
</div>
</template>

View File

@@ -1,5 +1,10 @@
<script setup lang="ts">
const { locale } = useI18n();
const clerkLocaleVersion = useState("clerk-locale-version", () => 0);
</script>
<template>
<div class="flex h-full w-full items-center justify-center">
<SignUp routing="path" path="/sign-up" />
<SignUp :key="`${locale}-${clerkLocaleVersion}`" routing="path" path="/sign-up" />
</div>
</template>

View File

@@ -1,56 +1,189 @@
<template>
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4">
<div class="flex flex-wrap gap-4 items-center justify-center">
<Button v-if="!localStream" @click="startScreenShare">
{{ $t("screenshare") }}
</Button>
<Button
v-if="localStream"
@click="changeScreenShareSource"
variant="outline"
>
{{ $t("changeSource") }}
</Button>
<PresetSelect />
<div
class="min-h-[80vh] flex flex-col items-center justify-start gap-8 mt-10 px-4 pb-16"
>
<div class="text-center space-y-2">
<h1 class="text-4xl font-bold tracking-tight">{{ $t("stream") }}</h1>
</div>
<div v-if="isElectron && supportsAudioScreenShare" class="flex flex-col items-center gap-2">
<div class="flex items-center gap-2">
<Switch id="include-audio" v-model:checked="includeAudio" />
<Label for="include-audio" class="text-sm">{{ $t("includeAudio") }}</Label>
</div>
<div v-if="platformInfo?.isLinux && platformInfo?.supportsVenmic && includeAudio" class="flex flex-col items-center gap-2">
<Select v-model="selectedAudioSource">
<SelectTrigger class="w-[200px]">
<SelectValue :placeholder="$t('audioSource')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ $t("allSystemAudio") }}</SelectItem>
<SelectItem
v-for="source in audioSources"
:key="source['node.name']"
:value="source['application.name']! || source['node.name']!"
<div
class="w-full max-w-5xl grid grid-cols-1 lg:grid-cols-[380px_1fr] gap-6 items-start"
>
<div class="space-y-4">
<Card>
<CardHeader>
<CardTitle class="text-base flex items-center gap-2">
<Monitor class="size-4 text-primary" />
{{ $t("screenshare") }}
</CardTitle>
<CardDescription>
{{ $t("streamControlDescription") }}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<Button
v-if="!localStream"
@click="startScreenShare"
size="lg"
class="w-full gap-2"
>
{{ source['application.name'] || source['node.name'] }}
</SelectItem>
</SelectContent>
</Select>
<Button variant="ghost" size="sm" @click="refreshAudioSources">
{{ $t("refreshSources") }}
</Button>
<Monitor class="size-4" />
{{ $t("screenshare") }}
</Button>
<Button
v-if="localStream"
@click="changeScreenShareSource"
variant="outline"
size="lg"
class="w-full gap-2"
>
<RefreshCw class="size-4" />
{{ $t("changeSource") }}
</Button>
<Button
v-if="localStream"
@click="stopStreaming"
variant="destructive"
size="lg"
class="w-full gap-2"
>
<Square class="size-4" />
{{ $t("stopStream") }}
</Button>
<Separator />
<div class="space-y-2">
<Label
class="text-xs text-muted-foreground uppercase tracking-wider font-semibold"
>
{{ $t("selectAPreset") }}
</Label>
<PresetSelect v-model="selectedPresetId" />
</div>
</CardContent>
</Card>
<Card v-if="streamerStore.code" class="border-primary/30">
<CardHeader class="pb-2">
<div class="flex items-center justify-between">
<CardTitle class="text-base flex items-center gap-2">
<Share2 class="size-4 text-primary" />
{{ $t("shareCode") }}
</CardTitle>
<Badge class="text-xs bg-green-500/15 text-green-600 dark:text-green-400 border-0">
{{ $t("live") }}
</Badge>
</div>
</CardHeader>
<CardContent class="space-y-3">
<p
class="font-mono text-5xl font-bold tracking-[0.3em] text-primary text-center py-2"
>
{{ streamerStore.code }}
</p>
<Button
variant="outline"
size="sm"
class="w-full gap-2"
@click="copyCode"
>
<Copy class="size-3" />
{{ $t("copyCode") }}
</Button>
</CardContent>
</Card>
<Card v-if="isElectron && supportsAudioScreenShare">
<CardHeader>
<CardTitle class="text-base flex items-center gap-2">
<Radio class="size-4 text-primary" />
{{ $t("includeAudio") }}
</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center gap-2">
<Switch id="include-audio" v-model="includeAudio" />
<Label for="include-audio" class="text-sm font-normal">
{{ $t("includeAudio") }}
</Label>
</div>
<div
v-if="platformInfo?.isLinux && platformInfo?.supportsVenmic && includeAudio"
class="space-y-3 pt-2 border-t border-border"
>
<Select v-model="selectedAudioSource">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('audioSource')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{{ $t("allSystemAudio") }}
</SelectItem>
<SelectItem
v-for="source in audioSources"
:key="source['node.name']"
:value="
source['application.name']! || source['node.name']!
"
>
{{ source['application.name'] || source['node.name'] }}
</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
@click="refreshAudioSources"
class="gap-2 w-full"
>
<RefreshCw class="size-3" />
{{ $t("refreshSources") }}
</Button>
</div>
</CardContent>
</Card>
</div>
<div>
<div
class="relative rounded-xl overflow-hidden border shadow-sm"
:class="[
localStream
? 'aspect-video bg-black'
: 'aspect-video bg-muted/50 flex items-center justify-center',
]"
>
<video
ref="videofeedRef"
autoplay
playsinline
muted
class="w-full h-full object-contain"
:class="{ hidden: !localStream }"
/>
<div
v-if="!localStream"
class="flex flex-col items-center gap-3 text-muted-foreground"
>
<div
class="size-16 rounded-full bg-muted flex items-center justify-center"
>
<Monitor class="size-8 opacity-40" />
</div>
<div class="text-center space-y-1">
<p class="text-sm font-medium">{{ $t("previewWaiting") }}</p>
<p class="text-xs opacity-60">{{ $t("previewWaitingDescription") }}</p>
</div>
</div>
</div>
</div>
</div>
<div v-if="isElectron" class="text-xs text-muted-foreground">
<span v-if="platformInfo?.isWindows">Windows</span>
<span v-else-if="platformInfo?.isMac">macOS</span>
<span v-else-if="platformInfo?.isLinux">Linux</span>
<span v-if="supportsAudioScreenShare" class="ml-2"> {{ $t("audioSupported") }}</span>
</div>
<p v-if="streamerStore.code" class="font-mono text-lg">{{ streamerStore.code }}</p>
<video ref="videofeedRef" autoplay playsinline muted class="max-w-full rounded-lg"></video>
</div>
</template>
@@ -60,20 +193,45 @@ import { toast } from "vue-sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import {
Copy,
Monitor,
Radio,
RefreshCw,
Share2,
Square,
} from "lucide-vue-next";
import { useStreamerStore } from "~/state/streamer";
import { useWebSocketUrl } from "~/composables/useWebSocketUrl";
import { useElectron } from "~/composables/useElectron";
import PresetSelect from "~/components/app/PresetSelect.vue";
const streamerStore = useStreamerStore();
const { t } = useI18n();
const { t, locale } = useI18n();
const videofeedRef = ref<HTMLVideoElement | null>(null);
const localStream = ref<MediaStream | null>(null);
const isCleaningUp = ref(false);
const wsUrl = useWebSocketUrl();
const includeAudio = ref(true);
const selectedAudioSource = ref("all");
const selectedPresetId = ref("");
const {
isElectron,
@@ -87,19 +245,68 @@ const {
unlinkVenmicAudio,
getScreenPermissionStatus,
openScreenPermissionSettings,
setStreamingActive,
} = useElectron();
onMounted(async () => {
await getPlatformInfo();
if (platformInfo.value?.isLinux && platformInfo.value?.supportsVenmic) {
await refreshAudioSources();
}
});
watch(localStream, async (stream) => {
await nextTick();
if (videofeedRef.value) {
videofeedRef.value.srcObject = stream;
}
});
watch([localStream, locale], async ([stream]) => {
if (!isElectron.value) return;
await setStreamingActive(
!!stream,
stream
? {
title: t("activeStreamCloseTitle"),
message: t("activeStreamCloseMessage"),
confirmLabel: t("stopStreamAndClose"),
cancelLabel: t("keepStreaming"),
}
: undefined,
);
});
async function refreshAudioSources() {
await getVenmicSources();
}
async function copyCode() {
await navigator.clipboard.writeText(streamerStore.code);
toast.success(t("codeCopied"));
}
function notifyRoomClosed() {
if (!streamerStore.code) return;
send(
JSON.stringify({
event: "close-room",
roomId: streamerStore.code,
}),
);
}
async function stopStreaming() {
if (isCleaningUp.value) return;
notifyRoomClosed();
await cleanupStreaming();
}
const { send, close: closeWebSocket } = useWebSocket(wsUrl, {
autoReconnect: true,
heartbeat: {
@@ -189,9 +396,17 @@ const { send, close: closeWebSocket } = useWebSocket(wsUrl, {
});
async function startScreenShare() {
if (!selectedPresetId.value) {
toast.error(t("selectPresetBeforeStreaming"));
return;
}
try {
const isLinuxWithVenmic = isElectron.value && platformInfo.value?.isLinux && platformInfo.value?.supportsVenmic;
const isLinuxWithVenmic =
isElectron.value &&
platformInfo.value?.isLinux &&
platformInfo.value?.supportsVenmic;
if (isLinuxWithVenmic && includeAudio.value) {
if (selectedAudioSource.value === "all") {
await linkAllAudio();
@@ -200,33 +415,34 @@ async function startScreenShare() {
}
}
const shouldRequestAudio = isElectron.value && includeAudio.value && supportsAudioScreenShare.value;
const shouldRequestAudio =
isElectron.value && includeAudio.value && supportsAudioScreenShare.value;
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: shouldRequestAudio ? {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
} : false,
audio: shouldRequestAudio
? {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
}
: false,
});
localStream.value = stream;
if (videofeedRef.value) {
videofeedRef.value.srcObject = stream;
}
stream.getTracks().forEach((track) => {
track.onended = () => {
console.log("Screen sharing stopped by user");
cleanupStreaming();
stopStreaming();
};
});
const videoTracks = stream.getVideoTracks();
const audioTracks = stream.getAudioTracks();
console.log(`[Helium] Stream started - Video: ${videoTracks.length}, Audio: ${audioTracks.length}`);
console.log(
`[Helium] Stream started - Video: ${videoTracks.length}, Audio: ${audioTracks.length}`,
);
send(
JSON.stringify({
@@ -242,8 +458,11 @@ async function startScreenShare() {
async function changeScreenShareSource() {
try {
const isLinuxWithVenmic = isElectron.value && platformInfo.value?.isLinux && platformInfo.value?.supportsVenmic;
const isLinuxWithVenmic =
isElectron.value &&
platformInfo.value?.isLinux &&
platformInfo.value?.supportsVenmic;
if (isLinuxWithVenmic && includeAudio.value) {
if (selectedAudioSource.value === "all") {
await linkAllAudio();
@@ -252,7 +471,8 @@ async function changeScreenShareSource() {
}
}
const shouldRequestAudio = isElectron.value && includeAudio.value && supportsAudioScreenShare.value;
const shouldRequestAudio =
isElectron.value && includeAudio.value && supportsAudioScreenShare.value;
const newStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
@@ -266,19 +486,23 @@ async function changeScreenShareSource() {
newVideoTrack!.onended = () => {
console.log("Screen sharing stopped by user");
cleanupStreaming();
stopStreaming();
};
Object.values(streamerStore.peerConnections).forEach((pc) => {
const senders = pc.getSenders();
const videoSender = senders.find((sender) => sender.track?.kind === "video");
const videoSender = senders.find(
(sender) => sender.track?.kind === "video",
);
if (videoSender) {
videoSender.replaceTrack(newVideoTrack!);
}
if (newAudioTrack) {
const audioSender = senders.find((sender) => sender.track?.kind === "audio");
const audioSender = senders.find(
(sender) => sender.track?.kind === "audio",
);
if (audioSender) {
audioSender.replaceTrack(newAudioTrack);
} else {
@@ -292,10 +516,6 @@ async function changeScreenShareSource() {
});
localStream.value = newStream;
if (videofeedRef.value) {
videofeedRef.value.srcObject = newStream;
}
} catch (error) {
console.error("Failed to change screen share source:", error);
await handleScreenShareError(error);
@@ -306,7 +526,11 @@ async function handleScreenShareError(error: unknown): Promise<void> {
const isPermissionDeniedError =
error instanceof DOMException && error.name === "NotAllowedError";
if (!isPermissionDeniedError || !isElectron.value || !platformInfo.value?.isMac) {
if (
!isPermissionDeniedError ||
!isElectron.value ||
!platformInfo.value?.isMac
) {
toast.error(t("failedToStartScreenShare"));
return;
}
@@ -329,28 +553,36 @@ async function handleScreenShareError(error: unknown): Promise<void> {
}
async function cleanupStreaming() {
if (localStream.value) {
localStream.value.getTracks().forEach((track) => {
track.stop();
if (isCleaningUp.value) return;
isCleaningUp.value = true;
try {
if (localStream.value) {
localStream.value.getTracks().forEach((track) => {
track.stop();
});
localStream.value = null;
}
if (isElectron.value && platformInfo.value?.isLinux) {
await unlinkVenmicAudio();
}
Object.values(streamerStore.peerConnections).forEach((pc) => {
pc.close();
});
localStream.value = null;
streamerStore.clearPeerConnections();
if (videofeedRef.value) {
videofeedRef.value.srcObject = null;
}
streamerStore.setCode("");
} finally {
isCleaningUp.value = false;
}
if (isElectron.value && platformInfo.value?.isLinux) {
await unlinkVenmicAudio();
}
Object.values(streamerStore.peerConnections).forEach((pc) => {
pc.close();
});
streamerStore.clearPeerConnections();
if (videofeedRef.value) {
videofeedRef.value.srcObject = null;
}
streamerStore.setCode("");
}
onBeforeUnmount(() => {

View File

@@ -1,44 +1,106 @@
import { updateClerkOptions } from "#imports";
import { esES } from "@clerk/localizations";
import { shadcn } from "@clerk/themes";
import type { Ref } from "vue";
export default defineNuxtPlugin((nuxtApp) => {
const i18n = nuxtApp.$i18n as any;
interface ClerkLocaleOptions {
localization: typeof esES | undefined;
appearance: {
theme: typeof shadcn;
};
}
if (!i18n) return;
interface I18nRuntime {
locale: Ref<string>;
}
interface MutablePublicConfig {
clerk?: Record<string, unknown>;
}
function getClerkLocaleOptions(locale: string): ClerkLocaleOptions {
return {
localization: locale === "es" ? esES : undefined,
appearance: {
theme: shadcn,
},
};
}
function getCookieValue(name: string): string | undefined {
const cookie = document.cookie
.split("; ")
.find((entry) => entry.startsWith(`${name}=`));
return cookie ? decodeURIComponent(cookie.split("=")[1] ?? "") : undefined;
}
function getInitialLocale(): string {
const cookieLocale = getCookieValue("i18n_locale");
if (cookieLocale) return cookieLocale;
if (navigator.language.toLowerCase().startsWith("es")) {
return "es";
}
return "en";
}
export default defineNuxtPlugin({
name: "clerk-locale",
enforce: "pre",
setup(nuxtApp) {
const runtimeConfig = useRuntimeConfig();
const clerkLocaleVersion = useState("clerk-locale-version", () => 0);
const publicConfig = runtimeConfig.public as MutablePublicConfig;
const initialLocale = getInitialLocale();
function updateRuntimeConfig(options: ClerkLocaleOptions): void {
publicConfig.clerk = {
...(publicConfig.clerk ?? {}),
...options,
};
}
updateRuntimeConfig(getClerkLocaleOptions(initialLocale));
function updateLocale(locale: string, attempts = 0): void {
const options = getClerkLocaleOptions(locale);
updateRuntimeConfig(options);
nuxtApp.hook("app:mounted", () => {
const checkClerk = () => {
try {
const testUpdate = () => {
updateClerkOptions({
localization: i18n.locale.value === "es" ? esES : undefined,
appearance: {
theme: shadcn,
},
});
};
testUpdate();
watch(
() => i18n.locale.value,
(newLocale) => {
const clerkLocale = newLocale === "es" ? esES : undefined;
updateClerkOptions({
localization: clerkLocale,
appearance: {
theme: shadcn,
},
});
},
);
} catch (e) {
setTimeout(checkClerk, 100);
updateClerkOptions(options);
clerkLocaleVersion.value += 1;
} catch {
if (attempts < 20) {
setTimeout(() => updateLocale(locale, attempts + 1), 100);
}
}
};
}
checkClerk();
});
nuxtApp.hook("i18n:beforeLocaleSwitch", (options) => {
updateLocale(options.newLocale);
});
nuxtApp.hook("i18n:localeSwitched", (options) => {
updateLocale(options.newLocale);
});
nuxtApp.hook("app:mounted", () => {
const i18n = nuxtApp.$i18n as I18nRuntime | undefined;
if (!i18n) {
updateLocale(initialLocale);
return;
}
watch(
() => i18n.locale.value,
(locale) => {
updateLocale(locale);
},
{ immediate: true },
);
});
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View File

@@ -3,6 +3,7 @@ import {
BrowserWindow,
ipcMain,
desktopCapturer,
dialog,
session,
shell,
systemPreferences,
@@ -48,6 +49,13 @@ const NUXT_DEV_URL = process.env.NUXT_DEV_URL || 'http://localhost:3000';
let mainWindow: BrowserWindow | null = null;
let venmicManager: VenmicManager | null = null;
let isStreamingActive = false;
let streamingClosePrompt = {
title: 'Active stream',
message: 'A stream is still active. Closing Helium will stop it for all viewers.',
confirmLabel: 'Stop stream and close',
cancelLabel: 'Keep streaming',
};
console.log('[Helium] Platform:', process.platform);
console.log('[Helium] Wayland:', isWayland);
@@ -89,6 +97,27 @@ function createWindow(): void {
mainWindow.loadURL(prodUrl);
}
mainWindow.on('close', (event) => {
if (!isStreamingActive || !mainWindow) return;
const choice = dialog.showMessageBoxSync(mainWindow, {
type: 'warning',
title: streamingClosePrompt.title,
message: streamingClosePrompt.message,
buttons: [streamingClosePrompt.cancelLabel, streamingClosePrompt.confirmLabel],
defaultId: 0,
cancelId: 0,
noLink: true,
});
if (choice === 0) {
event.preventDefault();
return;
}
isStreamingActive = false;
});
mainWindow.on('closed', () => {
mainWindow = null;
});
@@ -152,7 +181,7 @@ ipcMain.handle('helium:get-platform', () => {
isWindows,
isWayland,
isElectron: true,
supportsLoopbackAudio: isWindows || isMac,
supportsLoopbackAudio: isWindows || isMac || isLinux,
supportsVenmic: isLinux && (venmicManager?.isAvailable() ?? false),
};
});
@@ -223,6 +252,19 @@ ipcMain.handle('helium:open-screen-permission-settings', async () => {
}
});
ipcMain.handle(
'helium:set-streaming-active',
(_event: IpcMainInvokeEvent, active: boolean, promptOptions?: typeof streamingClosePrompt) => {
isStreamingActive = active;
if (promptOptions) {
streamingClosePrompt = promptOptions;
}
return true;
},
);
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {

View File

@@ -35,6 +35,13 @@ export interface VenmicLinkOptions {
only_default_speakers?: boolean;
}
export interface StreamingClosePromptOptions {
title: string;
message: string;
confirmLabel: string;
cancelLabel: string;
}
const heliumElectronAPI = {
isElectron: true as const,
getPlatform: (): Promise<PlatformInfo> => ipcRenderer.invoke('helium:get-platform'),
@@ -59,6 +66,10 @@ const heliumElectronAPI = {
checkScreenPermission: (): Promise<string> => ipcRenderer.invoke('helium:check-screen-permission'),
openScreenPermissionSettings: (): Promise<boolean> =>
ipcRenderer.invoke('helium:open-screen-permission-settings'),
setStreamingActive: (
active: boolean,
promptOptions?: StreamingClosePromptOptions,
): Promise<boolean> => ipcRenderer.invoke('helium:set-streaming-active', active, promptOptions),
};
contextBridge.exposeInMainWorld('heliumElectron', heliumElectronAPI);

View File

@@ -1,12 +1,16 @@
{
"welcome": "Welcome",
"language": "Language",
"selectLanguage": "Select language",
"home": "Home",
"about": "About",
"contact": "Contact",
"downloads": "Downloads",
"stream": "Stream",
"streamControlDescription": "Choose a source, tune your connection preset, then share the room code.",
"shareCode": "Stream code",
"copyCode": "Copy code",
"codeCopied": "Code copied to clipboard",
"live": "Live",
"previewWaiting": "Preview waiting",
"previewWaitingDescription": "Your selected screen will appear here once sharing starts.",
"presets": "Presets",
"effortlessScreensharing": "effortless screensharing powered by webrtc",
"hostInstead": "stream instead?",
@@ -18,6 +22,7 @@
"screenshare": "Screenshare",
"loadingPresets": "Loading presets...",
"selectAPreset": "Select a preset",
"selectPresetBeforeStreaming": "Select a preset before starting a stream.",
"noPresetsAvailable": "No presets available",
"default": "default",
"createNewPreset": "Create New Preset",
@@ -56,11 +61,15 @@
"presetSharingDisabled": "The preset owner has not enabled sharing for this preset.",
"goBack": "Go Back",
"changeSource": "Change Source",
"stopStream": "Stop Stream",
"activeStreamCloseTitle": "Active stream",
"activeStreamCloseMessage": "A stream is still active. Closing Helium will stop it for all viewers.",
"stopStreamAndClose": "Stop stream and close",
"keepStreaming": "Keep streaming",
"includeAudio": "Include Audio",
"audioSource": "Audio Source",
"allSystemAudio": "All System Audio",
"refreshSources": "Refresh Sources",
"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.",
@@ -70,11 +79,8 @@
"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.",
"desktopAppNote": "Includes advanced features like system audio capture and native screen recording permissions.",
"androidAppNote": "Install the APK directly on your Android device.",
"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"
"viewSourceCode": "View Source Code"
}

View File

@@ -1,12 +1,16 @@
{
"welcome": "Bienvenido",
"language": "Idioma",
"selectLanguage": "Seleccionar idioma",
"home": "Inicio",
"about": "Acerca de",
"contact": "Contacto",
"downloads": "Descargas",
"stream": "Transmisión",
"streamControlDescription": "Elige una fuente, ajusta el preset de conexión y comparte el código de la sala.",
"shareCode": "Código de la emisión",
"copyCode": "Copiar código",
"codeCopied": "Código copiado al portapapeles",
"live": "En vivo",
"previewWaiting": "Vista previa en espera",
"previewWaitingDescription": "La pantalla seleccionada aparecerá aquí cuando empieces a compartir.",
"presets": "Ajustes predefinidos",
"effortlessScreensharing": "comparte pantalla sin complicaciones",
"hostInstead": "¿prefieres transmitir pantalla?",
@@ -18,6 +22,7 @@
"screenshare": "Compartir pantalla",
"loadingPresets": "Cargando ajustes...",
"selectAPreset": "Seleccionar un ajuste",
"selectPresetBeforeStreaming": "Selecciona un ajuste antes de iniciar una transmisión.",
"noPresetsAvailable": "No hay ajustes disponibles",
"default": "predeterminado",
"createNewPreset": "Crear nuevo ajuste",
@@ -56,11 +61,15 @@
"presetSharingDisabled": "El propietario del ajuste no ha habilitado el uso compartido para este ajuste.",
"goBack": "Volver",
"changeSource": "Cambiar fuente",
"stopStream": "Detener transmisión",
"activeStreamCloseTitle": "Transmisión activa",
"activeStreamCloseMessage": "Hay una transmisión activa. Cerrar Helium la detendrá para todos los espectadores.",
"stopStreamAndClose": "Detener transmisión y cerrar",
"keepStreaming": "Seguir transmitiendo",
"includeAudio": "Incluir audio",
"audioSource": "Fuente de audio",
"allSystemAudio": "Todo el audio del sistema",
"refreshSources": "Actualizar fuentes",
"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.",
@@ -70,11 +79,8 @@
"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.",
"desktopAppNote": "Incluye funciones avanzadas como captura de audio del sistema y permisos de grabación de pantalla nativos.",
"androidAppNote": "Instala el APK directamente en tu dispositivo Android.",
"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"
"viewSourceCode": "Ver Código Fuente"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1 +1,18 @@
<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>
<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>

Before

Width:  |  Height:  |  Size: 546 B

After

Width:  |  Height:  |  Size: 606 B

View File

@@ -1 +1,18 @@
<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>
<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>

Before

Width:  |  Height:  |  Size: 543 B

After

Width:  |  Height:  |  Size: 606 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 141 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "helium",
"version": "0.1.1",
"version": "0.2.0",
"author": {
"email": "helium@srizan.dev",
"name": "eth0 software",
@@ -8,6 +8,7 @@
},
"type": "module",
"private": true,
"packageManager": "pnpm@10.33.0",
"main": "./electron/dist/main/index.js",
"scripts": {
"build": "nuxt build",

View File

@@ -54,6 +54,20 @@ async function deleteRoom(roomId: string) {
await db.delete(schema.rooms).where(eq(schema.rooms.id, roomId));
}
async function closeRoom(roomId: string, broadcasterId: string) {
const room = await getRoom(roomId);
if (!room || room.broadcaster !== broadcasterId) return;
room.viewers.forEach((viewerId: string) => {
const viewer = activePeers.get(viewerId);
if (viewer) {
viewer.send(JSON.stringify({ event: 'room-closed' }));
}
});
await deleteRoom(roomId);
}
async function addViewerToRoom(roomId: string, viewerId: string) {
await db.insert(schema.roomViewers).values({
roomId,
@@ -104,6 +118,9 @@ export default defineWebSocketHandler({
await createRoom(roomId, peer.id);
peer.send(JSON.stringify({ event: 'room-created', roomId }));
}
if (msg.event === 'close-room') {
await closeRoom(msg.roomId, peer.id);
}
if (msg.event === 'join-room') {
const room = await getRoom(msg.roomId);
if (room) {