mirror of
https://github.com/SrIzan10/helium.git
synced 2026-06-06 00:56:58 +00:00
feat: (mostly ai gen) electron app for native computer platforms
This commit is contained in:
254
app/composables/useElectron.ts
Normal file
254
app/composables/useElectron.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
export interface PlatformInfo {
|
||||
platform: string;
|
||||
isLinux: boolean;
|
||||
isMac: boolean;
|
||||
isWindows: boolean;
|
||||
isElectron: boolean;
|
||||
supportsLoopbackAudio: boolean;
|
||||
supportsVenmic: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopSource {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
appIcon: string | null;
|
||||
display_id?: string;
|
||||
}
|
||||
|
||||
export interface VenmicLinkOptions {
|
||||
include?: Record<string, string>[];
|
||||
exclude?: Record<string, string>[];
|
||||
ignore_devices?: boolean;
|
||||
only_speakers?: boolean;
|
||||
only_default_speakers?: boolean;
|
||||
}
|
||||
|
||||
interface HeliumElectronAPI {
|
||||
isElectron: boolean;
|
||||
getPlatform: () => Promise<PlatformInfo>;
|
||||
getSources: () => Promise<DesktopSource[]>;
|
||||
onSourcesAvailable: (callback: (sources: DesktopSource[]) => void) => void;
|
||||
selectSource: (sourceId: string | null) => void;
|
||||
removeSourcesListener: () => void;
|
||||
venmicAvailable: () => Promise<boolean>;
|
||||
venmicList: () => Promise<Record<string, string>[]>;
|
||||
venmicLink: (options: VenmicLinkOptions) => Promise<boolean>;
|
||||
venmicUnlink: () => Promise<boolean>;
|
||||
checkScreenPermission: () => Promise<string>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
heliumElectron?: HeliumElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export function useElectron() {
|
||||
const isElectron = ref(false);
|
||||
const platformInfo = ref<PlatformInfo | null>(null);
|
||||
const audioSources = ref<Record<string, string>[]>([]);
|
||||
const isVenmicLinked = ref(false);
|
||||
|
||||
const checkElectron = () => {
|
||||
if (import.meta.client) {
|
||||
isElectron.value = !!window.heliumElectron?.isElectron;
|
||||
}
|
||||
return isElectron.value;
|
||||
};
|
||||
|
||||
const getPlatformInfo = async (): Promise<PlatformInfo | null> => {
|
||||
if (!checkElectron()) {
|
||||
return {
|
||||
platform: 'browser',
|
||||
isLinux: false,
|
||||
isMac: false,
|
||||
isWindows: false,
|
||||
isElectron: false,
|
||||
supportsLoopbackAudio: false,
|
||||
supportsVenmic: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
platformInfo.value = await window.heliumElectron!.getPlatform();
|
||||
return platformInfo.value;
|
||||
} catch (error) {
|
||||
console.error('[useElectron] Failed to get platform info:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getDesktopSources = async (): Promise<DesktopSource[]> => {
|
||||
if (!checkElectron()) return [];
|
||||
|
||||
try {
|
||||
return await window.heliumElectron!.getSources();
|
||||
} catch (error) {
|
||||
console.error('[useElectron] Failed to get sources:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const onSourcesAvailable = (callback: (sources: DesktopSource[]) => void) => {
|
||||
if (!checkElectron()) return;
|
||||
window.heliumElectron!.onSourcesAvailable(callback);
|
||||
};
|
||||
|
||||
const selectSource = (sourceId: string | null) => {
|
||||
if (!checkElectron()) return;
|
||||
window.heliumElectron!.selectSource(sourceId);
|
||||
};
|
||||
|
||||
const removeSourcesListener = () => {
|
||||
if (!checkElectron()) return;
|
||||
window.heliumElectron!.removeSourcesListener();
|
||||
};
|
||||
|
||||
const isVenmicAvailable = async (): Promise<boolean> => {
|
||||
if (!checkElectron()) return false;
|
||||
try {
|
||||
return await window.heliumElectron!.venmicAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getVenmicSources = async (): Promise<Record<string, string>[]> => {
|
||||
if (!checkElectron()) return [];
|
||||
try {
|
||||
const sources = await window.heliumElectron!.venmicList();
|
||||
audioSources.value = sources;
|
||||
return sources;
|
||||
} catch (error) {
|
||||
console.error('[useElectron] Failed to list venmic sources:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const linkVenmicAudio = async (options: VenmicLinkOptions = {}): Promise<boolean> => {
|
||||
if (!checkElectron()) return false;
|
||||
try {
|
||||
const success = await window.heliumElectron!.venmicLink(options);
|
||||
isVenmicLinked.value = success;
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error('[useElectron] Failed to link venmic:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const linkAllAudio = async (): Promise<boolean> => {
|
||||
return linkVenmicAudio({
|
||||
exclude: [],
|
||||
ignore_devices: true,
|
||||
only_speakers: true,
|
||||
only_default_speakers: false,
|
||||
});
|
||||
};
|
||||
|
||||
const linkAppAudio = async (appName: string): Promise<boolean> => {
|
||||
return linkVenmicAudio({
|
||||
include: [{ 'application.name': appName }],
|
||||
});
|
||||
};
|
||||
|
||||
const unlinkVenmicAudio = async (): Promise<boolean> => {
|
||||
if (!checkElectron()) return false;
|
||||
try {
|
||||
const success = await window.heliumElectron!.venmicUnlink();
|
||||
isVenmicLinked.value = !success;
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error('[useElectron] Failed to unlink venmic:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const startScreenShareWithAudio = async (options: {
|
||||
video?: boolean | MediaTrackConstraints;
|
||||
audioSource?: 'all' | 'none' | string;
|
||||
} = {}): Promise<MediaStream | null> => {
|
||||
const { video = true, audioSource = 'all' } = options;
|
||||
const platform = await getPlatformInfo();
|
||||
|
||||
try {
|
||||
if (platform?.isLinux && platform.supportsVenmic && audioSource !== 'none') {
|
||||
if (audioSource === 'all') {
|
||||
await linkAllAudio();
|
||||
} else {
|
||||
await linkAppAudio(audioSource);
|
||||
}
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video,
|
||||
audio: audioSource !== 'none',
|
||||
});
|
||||
|
||||
return stream;
|
||||
} catch (error) {
|
||||
console.error('[useElectron] Failed to start screen share:', error);
|
||||
if (platform?.isLinux && isVenmicLinked.value) {
|
||||
await unlinkVenmicAudio();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const stopScreenShare = async (stream: MediaStream | null) => {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
const platform = platformInfo.value;
|
||||
if (platform?.isLinux && isVenmicLinked.value) {
|
||||
await unlinkVenmicAudio();
|
||||
}
|
||||
};
|
||||
|
||||
const supportsAudioScreenShare = computed(() => {
|
||||
if (!platformInfo.value) return false;
|
||||
return platformInfo.value.supportsLoopbackAudio || platformInfo.value.supportsVenmic;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
checkElectron();
|
||||
if (isElectron.value) {
|
||||
getPlatformInfo();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
removeSourcesListener();
|
||||
if (isVenmicLinked.value) {
|
||||
unlinkVenmicAudio();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isElectron: readonly(isElectron),
|
||||
platformInfo: readonly(platformInfo),
|
||||
audioSources: readonly(audioSources),
|
||||
isVenmicLinked: readonly(isVenmicLinked),
|
||||
supportsAudioScreenShare,
|
||||
|
||||
checkElectron,
|
||||
getPlatformInfo,
|
||||
getDesktopSources,
|
||||
onSourcesAvailable,
|
||||
selectSource,
|
||||
removeSourcesListener,
|
||||
|
||||
isVenmicAvailable,
|
||||
getVenmicSources,
|
||||
linkVenmicAudio,
|
||||
linkAllAudio,
|
||||
linkAppAudio,
|
||||
unlinkVenmicAudio,
|
||||
|
||||
startScreenShareWithAudio,
|
||||
stopScreenShare,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4">
|
||||
<div class="flex space-x-4 items-center">
|
||||
<div class="flex flex-wrap gap-4 items-center justify-center">
|
||||
<Button v-if="!localStream" @click="startScreenShare">
|
||||
{{ $t("screenshare") }}
|
||||
</Button>
|
||||
@@ -13,16 +13,56 @@
|
||||
</Button>
|
||||
<PresetSelect />
|
||||
</div>
|
||||
<p v-if="streamerStore.code" class="font-mono">{{ streamerStore.code }}</p>
|
||||
<video ref="videofeedRef" autoplay playsinline muted></video>
|
||||
|
||||
<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']!"
|
||||
>
|
||||
{{ source['application.name'] || source['node.name'] }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" @click="refreshAudioSources">
|
||||
{{ $t("refreshSources") }}
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useWebSocket } from "@vueuse/core";
|
||||
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 { useStreamerStore } from "~/state/streamer";
|
||||
import { useWebSocketUrl } from "~/composables/useWebSocketUrl";
|
||||
import { useElectron } from "~/composables/useElectron";
|
||||
import PresetSelect from "~/components/app/PresetSelect.vue";
|
||||
|
||||
const streamerStore = useStreamerStore();
|
||||
@@ -30,6 +70,32 @@ const videofeedRef = ref<HTMLVideoElement | null>(null);
|
||||
const localStream = ref<MediaStream | null>(null);
|
||||
const wsUrl = useWebSocketUrl();
|
||||
|
||||
const includeAudio = ref(true);
|
||||
const selectedAudioSource = ref("all");
|
||||
|
||||
const {
|
||||
isElectron,
|
||||
platformInfo,
|
||||
audioSources,
|
||||
supportsAudioScreenShare,
|
||||
getPlatformInfo,
|
||||
getVenmicSources,
|
||||
linkAllAudio,
|
||||
linkAppAudio,
|
||||
unlinkVenmicAudio,
|
||||
} = useElectron();
|
||||
|
||||
onMounted(async () => {
|
||||
await getPlatformInfo();
|
||||
if (platformInfo.value?.isLinux && platformInfo.value?.supportsVenmic) {
|
||||
await refreshAudioSources();
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshAudioSources() {
|
||||
await getVenmicSources();
|
||||
}
|
||||
|
||||
const { send, close: closeWebSocket } = useWebSocket(wsUrl, {
|
||||
autoReconnect: true,
|
||||
heartbeat: {
|
||||
@@ -120,9 +186,25 @@ const { send, close: closeWebSocket } = useWebSocket(wsUrl, {
|
||||
|
||||
async function startScreenShare() {
|
||||
try {
|
||||
const isLinuxWithVenmic = isElectron.value && platformInfo.value?.isLinux && platformInfo.value?.supportsVenmic;
|
||||
|
||||
if (isLinuxWithVenmic && includeAudio.value) {
|
||||
if (selectedAudioSource.value === "all") {
|
||||
await linkAllAudio();
|
||||
} else {
|
||||
await linkAppAudio(selectedAudioSource.value);
|
||||
}
|
||||
}
|
||||
|
||||
const shouldRequestAudio = isElectron.value && includeAudio.value && supportsAudioScreenShare.value;
|
||||
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: false,
|
||||
audio: shouldRequestAudio ? {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
} : false,
|
||||
});
|
||||
|
||||
localStream.value = stream;
|
||||
@@ -138,6 +220,10 @@ async function startScreenShare() {
|
||||
};
|
||||
});
|
||||
|
||||
const videoTracks = stream.getVideoTracks();
|
||||
const audioTracks = stream.getAudioTracks();
|
||||
console.log(`[Helium] Stream started - Video: ${videoTracks.length}, Audio: ${audioTracks.length}`);
|
||||
|
||||
send(
|
||||
JSON.stringify({
|
||||
event: "create-room",
|
||||
@@ -151,27 +237,48 @@ async function startScreenShare() {
|
||||
|
||||
async function changeScreenShareSource() {
|
||||
try {
|
||||
const isLinuxWithVenmic = isElectron.value && platformInfo.value?.isLinux && platformInfo.value?.supportsVenmic;
|
||||
|
||||
if (isLinuxWithVenmic && includeAudio.value) {
|
||||
if (selectedAudioSource.value === "all") {
|
||||
await linkAllAudio();
|
||||
} else {
|
||||
await linkAppAudio(selectedAudioSource.value);
|
||||
}
|
||||
}
|
||||
|
||||
const shouldRequestAudio = isElectron.value && includeAudio.value && supportsAudioScreenShare.value;
|
||||
|
||||
const newStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: false,
|
||||
audio: shouldRequestAudio,
|
||||
});
|
||||
|
||||
if (!localStream.value) return;
|
||||
|
||||
const newVideoTrack = newStream.getVideoTracks()[0];
|
||||
const newAudioTrack = newStream.getAudioTracks()[0];
|
||||
|
||||
newVideoTrack.onended = () => {
|
||||
newVideoTrack!.onended = () => {
|
||||
console.log("Screen sharing stopped by user");
|
||||
cleanupStreaming();
|
||||
};
|
||||
|
||||
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);
|
||||
videoSender.replaceTrack(newVideoTrack!);
|
||||
}
|
||||
|
||||
if (newAudioTrack) {
|
||||
const audioSender = senders.find((sender) => sender.track?.kind === "audio");
|
||||
if (audioSender) {
|
||||
audioSender.replaceTrack(newAudioTrack);
|
||||
} else {
|
||||
pc.addTrack(newAudioTrack, newStream);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -189,7 +296,7 @@ async function changeScreenShareSource() {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupStreaming() {
|
||||
async function cleanupStreaming() {
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
@@ -197,6 +304,10 @@ function cleanupStreaming() {
|
||||
localStream.value = null;
|
||||
}
|
||||
|
||||
if (isElectron.value && platformInfo.value?.isLinux) {
|
||||
await unlinkVenmicAudio();
|
||||
}
|
||||
|
||||
Object.values(streamerStore.peerConnections).forEach((pc) => {
|
||||
pc.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user