diff --git a/js/src/features/particles/autoplay-overlay.tsx b/js/src/features/particles/autoplay-overlay.tsx
deleted file mode 100644
index 0d459fc..0000000
--- a/js/src/features/particles/autoplay-overlay.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import { useNavigate } from "react-router-dom";
-import { X, Mic } from "lucide-react";
-import { useAutoplayStore } from "@/stores/autoplay-store";
-import { useDownloadUrl } from "@/hooks/use-download-url";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
-import { Small } from "@/components/ui/typography";
-import { getInitials } from "@/lib/utils";
-import { useNetwork } from "@/hooks/use-networks";
-
-interface AutoplayOverlayProps {
- networkId: string;
-}
-
-export function AutoplayOverlay({ networkId }: AutoplayOverlayProps) {
- const activeParticle = useAutoplayStore((s) => s.activeParticle);
- const streamId = useAutoplayStore((s) => s.streamId);
- const stop = useAutoplayStore((s) => s.stop);
- const { data: url } = useDownloadUrl(activeParticle?.properties.object_id);
- const navigate = useNavigate();
-
- const network = useNetwork(networkId);
-
- if (!activeParticle || !url) return null;
-
- const isVideo = activeParticle.properties.mime_type?.startsWith("video/");
- const creator = network?.humans?.find((h) => h.id === activeParticle.created_by_human_id);
- const senderInitials = creator ? getInitials(creator.email) : activeParticle.created_by_human_id.slice(0, 2).toUpperCase();
- const senderName = creator?.email_prefix ?? activeParticle.created_by_human_id;
-
- const handleClick = () => {
- stop();
- if (streamId) {
- navigate(`/${networkId}/${streamId}`);
- }
- };
-
- const closeButton = (
-
- );
-
- const senderBar = (
-
-
-
- {senderInitials}
-
-
-
{senderName}
-
- );
-
- return (
-
- {isVideo ? (
-
- {closeButton}
-
- {senderBar}
-
- ) : (
-
- {closeButton}
-
-
-
- {senderInitials}
-
-
-
- {senderName}
-
-
-
-
- )}
-
- );
-}
diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx
index 786afab..a1ac5d4 100644
--- a/js/src/features/particles/particle-list-view.tsx
+++ b/js/src/features/particles/particle-list-view.tsx
@@ -94,7 +94,7 @@ function StreamRow({
const userId = user?.id ?? "";
const network = useNetwork(networkId);
- useStreamAutoplay(latestChild, particle);
+ useStreamAutoplay(latestChild, particle, networkId, network);
const expiringSoon = useExpiringSoon(
particle.last_child_created_at,
diff --git a/js/src/features/particles/stream-card.tsx b/js/src/features/particles/stream-card.tsx
index 30c67d2..040707e 100644
--- a/js/src/features/particles/stream-card.tsx
+++ b/js/src/features/particles/stream-card.tsx
@@ -27,7 +27,7 @@ export const StreamCard = forwardRef
(function S
const userId = useAuthStore((s) => s.user?.id) ?? "";
const network = useNetwork(networkId);
- useStreamAutoplay(latestChild, particle);
+ useStreamAutoplay(latestChild, particle, networkId, network);
const expiringSoon = useExpiringSoon(
particle.last_child_created_at,
diff --git a/js/src/hooks/use-stream-autoplay.ts b/js/src/hooks/use-stream-autoplay.ts
index 8f0c4c8..8574e27 100644
--- a/js/src/hooks/use-stream-autoplay.ts
+++ b/js/src/hooks/use-stream-autoplay.ts
@@ -1,16 +1,21 @@
import { useEffect, useRef } from "react";
import beepSound from "../../assets/sound.wav";
-import type { Particle, StreamProperties } from "@/api/types";
+import type { Network, Particle, StreamProperties } from "@/api/types";
+import { apiClient } from "@/api/client";
import { useAuthStore } from "@/stores/auth-store";
import { useAutoplayStore } from "@/stores/autoplay-store";
+import { getInitials } from "@/lib/utils";
/**
* Triggers autoplay when a stream's latest child changes to a new media particle.
* Plays a beep sound for new text particles from other users.
+ * Sends autoplay data to the separate autoplay window via IPC.
*/
export function useStreamAutoplay(
latestChild: Particle | null,
streamParticle: Particle & { type: "stream"; properties: StreamProperties },
+ networkId: string,
+ network: Network | undefined,
) {
const userId = useAuthStore((s) => s.user?.id) ?? "";
const settledIdRef = useRef(undefined);
@@ -36,6 +41,26 @@ export function useStreamAutoplay(
if (latestChild.type !== "media") return;
- useAutoplayStore.getState().play(latestChild, streamParticle.id);
+ if (useAutoplayStore.getState().muted) return;
+
+ const particle = latestChild;
+ const creator = network?.humans?.find((h) => h.id === particle.created_by_human_id);
+ const senderName = creator?.email_prefix ?? particle.created_by_human_id;
+ const senderInitials = creator ? getInitials(creator.email) : particle.created_by_human_id.slice(0, 2).toUpperCase();
+
+ apiClient.getParticleDownloadUrl(particle.properties.object_id).then((downloadUrl) => {
+ window.electronAutoplay.play({
+ particleId: particle.id,
+ streamId: streamParticle.id,
+ networkId,
+ downloadUrl,
+ mimeType: particle.properties.mime_type,
+ durationMs: particle.properties.duration_ms,
+ senderName,
+ senderInitials,
+ });
+ }).catch(() => {
+ // Failed to get download URL — skip autoplay silently
+ });
}, [latestChild?.id]); // eslint-disable-line react-hooks/exhaustive-deps
}
diff --git a/js/src/huddle/HuddleApp.tsx b/js/src/huddle/HuddleApp.tsx
new file mode 100644
index 0000000..414206a
--- /dev/null
+++ b/js/src/huddle/HuddleApp.tsx
@@ -0,0 +1,47 @@
+import { Mic, MicOff, Video, VideoOff, PhoneOff } from 'lucide-react';
+import { useState } from 'react';
+
+export function HuddleApp() {
+ const [micOn, setMicOn] = useState(true);
+ const [videoOn, setVideoOn] = useState(true);
+
+ const handleLeave = () => {
+ window.electronWindow.closeHuddle();
+ };
+
+ return (
+
+ {/* Video grid */}
+
+
+ You
+
+
+ Participant
+
+
+
+ {/* Controls bar */}
+
+
+
+
+
+
+ );
+}
diff --git a/js/src/huddle_window/index.html b/js/src/huddle_window/index.html
new file mode 100644
index 0000000..edc0e47
--- /dev/null
+++ b/js/src/huddle_window/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+ llink - Huddle
+
+
+
+
+
+
diff --git a/js/src/huddle_window/renderer.tsx b/js/src/huddle_window/renderer.tsx
new file mode 100644
index 0000000..49ee87c
--- /dev/null
+++ b/js/src/huddle_window/renderer.tsx
@@ -0,0 +1,6 @@
+import { createRoot } from 'react-dom/client';
+import { HuddleApp } from '@/huddle/HuddleApp';
+import '@/styles/globals.css';
+
+const root = createRoot(document.getElementById('root')!);
+root.render();
diff --git a/js/src/lib/autoplay-ipc.ts b/js/src/lib/autoplay-ipc.ts
new file mode 100644
index 0000000..4ab65da
--- /dev/null
+++ b/js/src/lib/autoplay-ipc.ts
@@ -0,0 +1,10 @@
+export interface AutoplayPayload {
+ particleId: string;
+ streamId: string;
+ networkId: string;
+ downloadUrl: string;
+ mimeType: string;
+ durationMs: number;
+ senderName: string;
+ senderInitials: string;
+}
diff --git a/js/src/main.ts b/js/src/main.ts
index f8e5209..a513c3c 100644
--- a/js/src/main.ts
+++ b/js/src/main.ts
@@ -1,4 +1,4 @@
-import { app, BrowserWindow, ipcMain, session, shell } from 'electron';
+import { app, BrowserWindow, ipcMain, screen, session, shell } from 'electron';
import path from 'node:path';
import started from 'electron-squirrel-startup';
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
@@ -22,9 +22,12 @@ if (process.platform === 'darwin' && !app.isPackaged) {
app.dock?.setIcon(path.join(__dirname, '../../assets/icon.png'));
}
+let mainWindow: BrowserWindow | null = null;
+let autoplayWindow: BrowserWindow | null = null;
+let huddleWindow: BrowserWindow | null = null;
+
const createWindow = () => {
- // Create the browser window.
- const mainWindow = new BrowserWindow({
+ mainWindow = new BrowserWindow({
width: 800,
height: 600,
resizable: true,
@@ -35,7 +38,6 @@ const createWindow = () => {
},
});
- // and load the index.html of the app.
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
mainWindow.webContents.openDevTools();
@@ -44,8 +46,80 @@ const createWindow = () => {
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`),
);
}
+
+ mainWindow.on('closed', () => {
+ mainWindow = null;
+ });
};
+const createAutoplayWindow = () => {
+ if (autoplayWindow) return;
+
+ autoplayWindow = new BrowserWindow({
+ width: 320,
+ height: 88,
+ resizable: false,
+ frame: false,
+ alwaysOnTop: true,
+ skipTaskbar: true,
+ focusable: false,
+ show: false,
+ webPreferences: {
+ preload: path.join(__dirname, 'preload.js'),
+ },
+ });
+
+ if (AUTOPLAY_WINDOW_VITE_DEV_SERVER_URL) {
+ autoplayWindow.loadURL(AUTOPLAY_WINDOW_VITE_DEV_SERVER_URL);
+ } else {
+ autoplayWindow.loadFile(
+ path.join(__dirname, `../renderer/${AUTOPLAY_WINDOW_VITE_NAME}/index.html`),
+ );
+ }
+
+ autoplayWindow.on('closed', () => {
+ autoplayWindow = null;
+ });
+};
+
+const createHuddleWindow = () => {
+ if (huddleWindow) {
+ huddleWindow.focus();
+ return;
+ }
+
+ huddleWindow = new BrowserWindow({
+ width: 1024,
+ height: 768,
+ resizable: true,
+ fullscreenable: true,
+ frame: true,
+ title: 'Huddle',
+ webPreferences: {
+ preload: path.join(__dirname, 'preload.js'),
+ },
+ });
+
+ if (HUDDLE_WINDOW_VITE_DEV_SERVER_URL) {
+ huddleWindow.loadURL(HUDDLE_WINDOW_VITE_DEV_SERVER_URL);
+ } else {
+ huddleWindow.loadFile(
+ path.join(__dirname, `../renderer/${HUDDLE_WINDOW_VITE_NAME}/index.html`),
+ );
+ }
+
+ huddleWindow.on('closed', () => {
+ huddleWindow = null;
+ });
+};
+
+function positionAutoplayWindow() {
+ if (!autoplayWindow) return;
+ const { width } = screen.getPrimaryDisplay().workAreaSize;
+ const [winW] = autoplayWindow.getSize();
+ autoplayWindow.setPosition(width - winW - 16, 16);
+}
+
// Window control IPC handlers
ipcMain.on('window:minimize', (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize();
@@ -66,6 +140,39 @@ ipcMain.on('window:fullscreen', (event) => {
if (win) win.setFullScreen(!win.isFullScreen());
});
+// Secondary window IPC handlers
+ipcMain.on('window:open-huddle', () => {
+ createHuddleWindow();
+});
+ipcMain.on('window:close-huddle', () => {
+ huddleWindow?.close();
+});
+
+// Autoplay IPC handlers
+ipcMain.on('autoplay:play', (_event, payload) => {
+ if (!autoplayWindow) createAutoplayWindow();
+ if (!autoplayWindow) return;
+
+ const isVideo = typeof payload?.mimeType === 'string' && payload.mimeType.startsWith('video/');
+ autoplayWindow.setSize(320, isVideo ? 260 : 88);
+ positionAutoplayWindow();
+
+ autoplayWindow.webContents.send('autoplay:play', payload);
+ autoplayWindow.showInactive();
+});
+
+ipcMain.on('autoplay:dismiss', () => {
+ autoplayWindow?.hide();
+});
+
+ipcMain.on('autoplay:navigate', (_event, data) => {
+ autoplayWindow?.hide();
+ if (mainWindow) {
+ mainWindow.webContents.send('autoplay:navigate', data);
+ mainWindow.focus();
+ }
+});
+
// --- Link metadata ---
const metadataCache = new Map();
@@ -212,6 +319,7 @@ app.on('ready', () => {
);
createWindow();
+ createAutoplayWindow();
});
// Quit when all windows are closed, except on macOS. There, it's common
diff --git a/js/src/preload.ts b/js/src/preload.ts
index 449bbba..3eb5c94 100644
--- a/js/src/preload.ts
+++ b/js/src/preload.ts
@@ -7,6 +7,24 @@ contextBridge.exposeInMainWorld('electronWindow', {
maximize: () => ipcRenderer.send('window:maximize'),
fullscreen: () => ipcRenderer.send('window:fullscreen'),
close: () => ipcRenderer.send('window:close'),
+ openHuddle: () => ipcRenderer.send('window:open-huddle'),
+ closeHuddle: () => ipcRenderer.send('window:close-huddle'),
+});
+
+contextBridge.exposeInMainWorld('electronAutoplay', {
+ play: (payload: unknown) => ipcRenderer.send('autoplay:play', payload),
+ dismiss: () => ipcRenderer.send('autoplay:dismiss'),
+ navigate: (data: { networkId: string; streamId: string }) => ipcRenderer.send('autoplay:navigate', data),
+ onPlay: (callback: (payload: unknown) => void) => {
+ const handler = (_event: Electron.IpcRendererEvent, payload: unknown) => callback(payload);
+ ipcRenderer.on('autoplay:play', handler);
+ return () => { ipcRenderer.removeListener('autoplay:play', handler); };
+ },
+ onNavigate: (callback: (data: { networkId: string; streamId: string }) => void) => {
+ const handler = (_event: Electron.IpcRendererEvent, data: { networkId: string; streamId: string }) => callback(data);
+ ipcRenderer.on('autoplay:navigate', handler);
+ return () => { ipcRenderer.removeListener('autoplay:navigate', handler); };
+ },
});
contextBridge.exposeInMainWorld('electronLink', {
diff --git a/js/src/stores/autoplay-store.ts b/js/src/stores/autoplay-store.ts
index 5d4e34d..87f98b1 100644
--- a/js/src/stores/autoplay-store.ts
+++ b/js/src/stores/autoplay-store.ts
@@ -1,7 +1,4 @@
import { create } from "zustand";
-import type { Particle } from "@/api/types";
-
-type MediaParticle = Extract;
const STORAGE_KEY = "llink:autoplay-muted";
@@ -22,38 +19,17 @@ function saveMuted(muted: boolean) {
}
interface AutoplayState {
- /** The media particle currently being autoplayed, null when idle */
- activeParticle: MediaParticle | null;
- /** The stream this particle belongs to (for click-to-navigate) */
- streamId: string | null;
/** When true, incoming play requests are ignored */
muted: boolean;
- /** Play a media particle, preempting any currently playing. No-op when muted. */
- play: (particle: MediaParticle, streamId: string) => void;
- /** Stop autoplay and clear state */
- stop: () => void;
/** Toggle muted state */
toggleMuted: () => void;
}
export const useAutoplayStore = create((set, get) => ({
- activeParticle: null,
- streamId: null,
muted: loadMuted(),
- play: (particle, streamId) => {
- if (get().muted) return;
- set({ activeParticle: particle, streamId });
- },
- stop: () => set({ activeParticle: null, streamId: null }),
toggleMuted: () => {
- const wasMuted = get().muted;
- // If muting, also stop any current playback
- if (!wasMuted) {
- set({ muted: true, activeParticle: null, streamId: null });
- saveMuted(true);
- } else {
- set({ muted: false });
- saveMuted(false);
- }
+ const next = !get().muted;
+ set({ muted: next });
+ saveMuted(next);
},
}));
diff --git a/js/src/vite-env.d.ts b/js/src/vite-env.d.ts
new file mode 100644
index 0000000..6c35629
--- /dev/null
+++ b/js/src/vite-env.d.ts
@@ -0,0 +1,8 @@
+// Electron Forge VitePlugin injects these globals at dev/build time.
+// Each renderer entry in forge.config.ts gets a pair of constants.
+declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
+declare const MAIN_WINDOW_VITE_NAME: string;
+declare const AUTOPLAY_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
+declare const AUTOPLAY_WINDOW_VITE_NAME: string;
+declare const HUDDLE_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
+declare const HUDDLE_WINDOW_VITE_NAME: string;
diff --git a/js/vite.autoplay.config.mts b/js/vite.autoplay.config.mts
new file mode 100644
index 0000000..14650e2
--- /dev/null
+++ b/js/vite.autoplay.config.mts
@@ -0,0 +1,20 @@
+import path from "path"
+import tailwindcss from "@tailwindcss/vite"
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+// https://vitejs.dev/config
+export default defineConfig({
+ root: path.resolve(__dirname, './src/autoplay_window'),
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ server: {
+ fs: {
+ allow: [path.resolve(__dirname)],
+ },
+ },
+});
diff --git a/js/vite.huddle.config.mts b/js/vite.huddle.config.mts
new file mode 100644
index 0000000..b86d70c
--- /dev/null
+++ b/js/vite.huddle.config.mts
@@ -0,0 +1,20 @@
+import path from "path"
+import tailwindcss from "@tailwindcss/vite"
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+// https://vitejs.dev/config
+export default defineConfig({
+ root: path.resolve(__dirname, './src/huddle_window'),
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ server: {
+ fs: {
+ allow: [path.resolve(__dirname)],
+ },
+ },
+});