From 1effd9bb43d9381f6127984109a43cfdf0ee78e8 Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 31 Mar 2026 13:35:53 -0700 Subject: [PATCH 1/4] prototype with two windows --- js/forge.config.ts | 8 +++ js/src/autoplay/AutoplayApp.tsx | 7 +++ js/src/autoplay_window/index.html | 11 ++++ js/src/autoplay_window/renderer.tsx | 6 +++ js/src/electron.d.ts | 4 ++ js/src/features/network-root.tsx | 7 ++- js/src/huddle/HuddleApp.tsx | 47 +++++++++++++++++ js/src/huddle_window/index.html | 11 ++++ js/src/huddle_window/renderer.tsx | 6 +++ js/src/main.ts | 79 +++++++++++++++++++++++++++++ js/src/preload.ts | 4 ++ js/src/vite-env.d.ts | 8 +++ js/vite.autoplay.config.mts | 20 ++++++++ js/vite.huddle.config.mts | 20 ++++++++ 14 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 js/src/autoplay/AutoplayApp.tsx create mode 100644 js/src/autoplay_window/index.html create mode 100644 js/src/autoplay_window/renderer.tsx create mode 100644 js/src/huddle/HuddleApp.tsx create mode 100644 js/src/huddle_window/index.html create mode 100644 js/src/huddle_window/renderer.tsx create mode 100644 js/src/vite-env.d.ts create mode 100644 js/vite.autoplay.config.mts create mode 100644 js/vite.huddle.config.mts diff --git a/js/forge.config.ts b/js/forge.config.ts index a8b063c..e8dcd90 100644 --- a/js/forge.config.ts +++ b/js/forge.config.ts @@ -68,6 +68,14 @@ const config: ForgeConfig = { name: 'main_window', config: 'vite.renderer.config.mts', }, + { + name: 'autoplay_window', + config: 'vite.autoplay.config.mts', + }, + { + name: 'huddle_window', + config: 'vite.huddle.config.mts', + }, ], }), // Fuses are used to enable/disable various Electron functionality diff --git a/js/src/autoplay/AutoplayApp.tsx b/js/src/autoplay/AutoplayApp.tsx new file mode 100644 index 0000000..2f1b651 --- /dev/null +++ b/js/src/autoplay/AutoplayApp.tsx @@ -0,0 +1,7 @@ +export function AutoplayApp() { + return ( +
+

Autoplay Window

+
+ ); +} diff --git a/js/src/autoplay_window/index.html b/js/src/autoplay_window/index.html new file mode 100644 index 0000000..2dcab0c --- /dev/null +++ b/js/src/autoplay_window/index.html @@ -0,0 +1,11 @@ + + + + + llink - Autoplay + + +
+ + + diff --git a/js/src/autoplay_window/renderer.tsx b/js/src/autoplay_window/renderer.tsx new file mode 100644 index 0000000..ba5267b --- /dev/null +++ b/js/src/autoplay_window/renderer.tsx @@ -0,0 +1,6 @@ +import { createRoot } from 'react-dom/client'; +import { AutoplayApp } from '@/autoplay/AutoplayApp'; +import '@/styles/globals.css'; + +const root = createRoot(document.getElementById('root')!); +root.render(); diff --git a/js/src/electron.d.ts b/js/src/electron.d.ts index 758d8b0..3eb329a 100644 --- a/js/src/electron.d.ts +++ b/js/src/electron.d.ts @@ -7,6 +7,10 @@ declare global { maximize: () => void; fullscreen: () => void; close: () => void; + openAutoplay: () => void; + closeAutoplay: () => void; + openHuddle: () => void; + closeHuddle: () => void; }; electronLink: { fetchMetadata: (url: string) => Promise; diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index 1d4e16b..68c1a18 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { List, LayoutGrid } from "lucide-react"; import { particlePath } from "@/lib/particle-path"; @@ -25,6 +25,11 @@ export default function NetworkRoot() { const viewMode = useViewModeStore((s) => s.viewMode); const setViewMode = useViewModeStore((s) => s.setViewMode); + useEffect(() => { + window.electronWindow.openAutoplay(); + window.electronWindow.openHuddle(); + }, []); + const { streams } = useStreamParticles(path); const userId = useAuthStore((s) => s.user?.id); useDockBadge(streams, userId); 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/main.ts b/js/src/main.ts index f8e5209..16d69a4 100644 --- a/js/src/main.ts +++ b/js/src/main.ts @@ -22,6 +22,9 @@ if (process.platform === 'darwin' && !app.isPackaged) { app.dock?.setIcon(path.join(__dirname, '../../assets/icon.png')); } +let autoplayWindow: BrowserWindow | null = null; +let huddleWindow: BrowserWindow | null = null; + const createWindow = () => { // Create the browser window. const mainWindow = new BrowserWindow({ @@ -46,6 +49,68 @@ const createWindow = () => { } }; +const createAutoplayWindow = () => { + if (autoplayWindow) { + autoplayWindow.focus(); + return; + } + + autoplayWindow = new BrowserWindow({ + width: 300, + height: 100, + resizable: false, + frame: false, + alwaysOnTop: true, + skipTaskbar: true, + 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; + }); +}; + // Window control IPC handlers ipcMain.on('window:minimize', (event) => { BrowserWindow.fromWebContents(event.sender)?.minimize(); @@ -66,6 +131,20 @@ ipcMain.on('window:fullscreen', (event) => { if (win) win.setFullScreen(!win.isFullScreen()); }); +// Secondary window IPC handlers +ipcMain.on('window:open-autoplay', () => { + createAutoplayWindow(); +}); +ipcMain.on('window:close-autoplay', () => { + autoplayWindow?.close(); +}); +ipcMain.on('window:open-huddle', () => { + createHuddleWindow(); +}); +ipcMain.on('window:close-huddle', () => { + huddleWindow?.close(); +}); + // --- Link metadata --- const metadataCache = new Map(); diff --git a/js/src/preload.ts b/js/src/preload.ts index 449bbba..00acb92 100644 --- a/js/src/preload.ts +++ b/js/src/preload.ts @@ -7,6 +7,10 @@ contextBridge.exposeInMainWorld('electronWindow', { maximize: () => ipcRenderer.send('window:maximize'), fullscreen: () => ipcRenderer.send('window:fullscreen'), close: () => ipcRenderer.send('window:close'), + openAutoplay: () => ipcRenderer.send('window:open-autoplay'), + closeAutoplay: () => ipcRenderer.send('window:close-autoplay'), + openHuddle: () => ipcRenderer.send('window:open-huddle'), + closeHuddle: () => ipcRenderer.send('window:close-huddle'), }); contextBridge.exposeInMainWorld('electronLink', { 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)], + }, + }, +}); -- 2.54.0 From aaedd0e667c285370f3938aeb1972a5f3dcf583d Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 31 Mar 2026 13:36:51 -0700 Subject: [PATCH 2/4] use deps in separate window render process --- js/src/autoplay/AutoplayApp.tsx | 36 +++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/js/src/autoplay/AutoplayApp.tsx b/js/src/autoplay/AutoplayApp.tsx index 2f1b651..cabf1d8 100644 --- a/js/src/autoplay/AutoplayApp.tsx +++ b/js/src/autoplay/AutoplayApp.tsx @@ -1,7 +1,39 @@ +import { Mic, X } from 'lucide-react'; + export function AutoplayApp() { + const handleClose = () => { + window.electronWindow.closeAutoplay(); + }; + return ( -
-

Autoplay Window

+
+
+ + +
+
+
+ JD +
+ +
+
+

john.doe

+

+ Playing audio... +

+
+
+ +
+
+
+
); } -- 2.54.0 From a3d479a036b54a0ee7f8dc38571acb6d04b8729c Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 31 Mar 2026 14:18:35 -0700 Subject: [PATCH 3/4] enhance autoplay into desktop overlay --- js/src/App.tsx | 15 ++- js/src/autoplay/AutoplayApp.tsx | 102 +++++++++++++----- js/src/autoplay_window/index.html | 3 + js/src/electron.d.ts | 10 +- js/src/features/network-root.tsx | 9 +- .../features/particles/autoplay-overlay.tsx | 95 ---------------- .../features/particles/particle-list-view.tsx | 2 +- js/src/features/particles/stream-card.tsx | 2 +- js/src/hooks/use-stream-autoplay.ts | 29 ++++- js/src/lib/autoplay-ipc.ts | 10 ++ js/src/main.ts | 63 ++++++++--- js/src/preload.ts | 18 +++- js/src/stores/autoplay-store.ts | 30 +----- 13 files changed, 207 insertions(+), 181 deletions(-) delete mode 100644 js/src/features/particles/autoplay-overlay.tsx create mode 100644 js/src/lib/autoplay-ipc.ts diff --git a/js/src/App.tsx b/js/src/App.tsx index a7d3f43..4a40ff9 100644 --- a/js/src/App.tsx +++ b/js/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { HashRouter, Routes, Route } from "react-router-dom"; +import { HashRouter, Routes, Route, useNavigate } from "react-router-dom"; import { TooltipProvider } from "@/components/ui/tooltip"; import { useAuthStore } from "@/stores/auth-store"; import { LoginPage } from "@/features/auth/login-page"; @@ -42,9 +42,22 @@ const App = () => { return ; }; +function AutoplayNavigationListener() { + const navigate = useNavigate(); + + useEffect(() => { + return window.electronAutoplay.onNavigate((data) => { + navigate(`/${data.networkId}/${data.streamId}`); + }); + }, [navigate]); + + return null; +} + function AuthenticatedApp() { return ( + } /> diff --git a/js/src/autoplay/AutoplayApp.tsx b/js/src/autoplay/AutoplayApp.tsx index cabf1d8..b3cab74 100644 --- a/js/src/autoplay/AutoplayApp.tsx +++ b/js/src/autoplay/AutoplayApp.tsx @@ -1,39 +1,89 @@ -import { Mic, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { X } from 'lucide-react'; +import type { AutoplayPayload } from '@/lib/autoplay-ipc'; export function AutoplayApp() { - const handleClose = () => { - window.electronWindow.closeAutoplay(); + const [payload, setPayload] = useState(null); + + useEffect(() => { + return window.electronAutoplay.onPlay((p) => setPayload(p)); + }, []); + + if (!payload) { + return
; + } + + const isVideo = payload.mimeType.startsWith('video/'); + + const handleClick = () => { + window.electronAutoplay.navigate({ + networkId: payload.networkId, + streamId: payload.streamId, + }); + }; + + const handleClose = (e: React.MouseEvent) => { + e.stopPropagation(); + setPayload(null); + window.electronAutoplay.dismiss(); + }; + + const handleEnded = () => { + setPayload(null); + window.electronAutoplay.dismiss(); }; return ( -
-
- - -
-
-
- JD +
+ {isVideo ? ( +
+ +
+ ) : ( +
+ +
+ {payload.senderInitials}
-

john.doe

-

- Playing audio... -

+

{payload.senderName}

+

Playing audio...

+
- -
-
-
-
+ )}
); } diff --git a/js/src/autoplay_window/index.html b/js/src/autoplay_window/index.html index 2dcab0c..648a97a 100644 --- a/js/src/autoplay_window/index.html +++ b/js/src/autoplay_window/index.html @@ -3,6 +3,9 @@ llink - Autoplay +
diff --git a/js/src/electron.d.ts b/js/src/electron.d.ts index 3eb329a..af2a989 100644 --- a/js/src/electron.d.ts +++ b/js/src/electron.d.ts @@ -1,4 +1,5 @@ import type { LinkMetadata } from './lib/link-metadata'; +import type { AutoplayPayload } from './lib/autoplay-ipc'; declare global { interface Window { @@ -7,11 +8,16 @@ declare global { maximize: () => void; fullscreen: () => void; close: () => void; - openAutoplay: () => void; - closeAutoplay: () => void; openHuddle: () => void; closeHuddle: () => void; }; + electronAutoplay: { + play: (payload: AutoplayPayload) => void; + dismiss: () => void; + navigate: (data: { networkId: string; streamId: string }) => void; + onPlay: (callback: (payload: AutoplayPayload) => void) => () => void; + onNavigate: (callback: (data: { networkId: string; streamId: string }) => void) => () => void; + }; electronLink: { fetchMetadata: (url: string) => Promise; openExternal: (url: string) => Promise; diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index 68c1a18..4f37daa 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -1,10 +1,9 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { List, LayoutGrid } from "lucide-react"; import { particlePath } from "@/lib/particle-path"; import { ParticleListView } from "@/features/particles/particle-list-view"; import { ParticleGridView } from "@/features/particles/particle-grid-view"; -import { AutoplayOverlay } from "@/features/particles/autoplay-overlay"; import ControlsIndicator from "@/features/compose/controls-indicator"; import { ComposeOverlay } from "./compose/compose-overlay"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; @@ -25,11 +24,6 @@ export default function NetworkRoot() { const viewMode = useViewModeStore((s) => s.viewMode); const setViewMode = useViewModeStore((s) => s.setViewMode); - useEffect(() => { - window.electronWindow.openAutoplay(); - window.electronWindow.openHuddle(); - }, []); - const { streams } = useStreamParticles(path); const userId = useAuthStore((s) => s.user?.id); useDockBadge(streams, userId); @@ -76,7 +70,6 @@ export default function NetworkRoot() {
-
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} -
- ) : ( -
- {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/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 16d69a4..f8366b2 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,12 +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, @@ -38,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(); @@ -47,21 +46,26 @@ const createWindow = () => { path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`), ); } + + mainWindow.on('closed', () => { + mainWindow = null; + }); }; const createAutoplayWindow = () => { - if (autoplayWindow) { - autoplayWindow.focus(); - return; - } + if (autoplayWindow) return; autoplayWindow = new BrowserWindow({ - width: 300, - height: 100, + width: 320, + height: 88, resizable: false, frame: false, alwaysOnTop: true, skipTaskbar: true, + focusable: false, + show: false, + transparent: true, + hasShadow: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), }, @@ -111,6 +115,13 @@ const createHuddleWindow = () => { }); }; +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(); @@ -132,12 +143,6 @@ ipcMain.on('window:fullscreen', (event) => { }); // Secondary window IPC handlers -ipcMain.on('window:open-autoplay', () => { - createAutoplayWindow(); -}); -ipcMain.on('window:close-autoplay', () => { - autoplayWindow?.close(); -}); ipcMain.on('window:open-huddle', () => { createHuddleWindow(); }); @@ -145,6 +150,31 @@ 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(); @@ -291,6 +321,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 00acb92..3eb5c94 100644 --- a/js/src/preload.ts +++ b/js/src/preload.ts @@ -7,12 +7,26 @@ contextBridge.exposeInMainWorld('electronWindow', { maximize: () => ipcRenderer.send('window:maximize'), fullscreen: () => ipcRenderer.send('window:fullscreen'), close: () => ipcRenderer.send('window:close'), - openAutoplay: () => ipcRenderer.send('window:open-autoplay'), - closeAutoplay: () => ipcRenderer.send('window:close-autoplay'), 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', { fetchMetadata: (url: string) => ipcRenderer.invoke('link:fetch-metadata', url), openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url), 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); }, })); -- 2.54.0 From e6b478c2cf727cbaeaf7c5b30d488bfe70db6fcf Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 31 Mar 2026 14:49:41 -0700 Subject: [PATCH 4/4] fix: autoplay window not applying tailwind styles --- .../AutoplayApp.tsx | 74 ++++++++++--------- js/src/autoplay_window/index.html | 3 - js/src/autoplay_window/renderer.tsx | 2 +- js/src/main.ts | 2 - 4 files changed, 41 insertions(+), 40 deletions(-) rename js/src/{autoplay => autoplay_window}/AutoplayApp.tsx (54%) diff --git a/js/src/autoplay/AutoplayApp.tsx b/js/src/autoplay_window/AutoplayApp.tsx similarity index 54% rename from js/src/autoplay/AutoplayApp.tsx rename to js/src/autoplay_window/AutoplayApp.tsx index b3cab74..89a3ea4 100644 --- a/js/src/autoplay/AutoplayApp.tsx +++ b/js/src/autoplay_window/AutoplayApp.tsx @@ -1,14 +1,21 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { X } from 'lucide-react'; import type { AutoplayPayload } from '@/lib/autoplay-ipc'; export function AutoplayApp() { const [payload, setPayload] = useState(null); + const mediaRef = useRef(null); useEffect(() => { return window.electronAutoplay.onPlay((p) => setPayload(p)); }, []); + const stop = useCallback(() => { + mediaRef.current?.pause(); + setPayload(null); + window.electronAutoplay.dismiss(); + }, []); + if (!payload) { return
; } @@ -16,6 +23,7 @@ export function AutoplayApp() { const isVideo = payload.mimeType.startsWith('video/'); const handleClick = () => { + mediaRef.current?.pause(); window.electronAutoplay.navigate({ networkId: payload.networkId, streamId: payload.streamId, @@ -24,65 +32,63 @@ export function AutoplayApp() { const handleClose = (e: React.MouseEvent) => { e.stopPropagation(); - setPayload(null); - window.electronAutoplay.dismiss(); - }; - - const handleEnded = () => { - setPayload(null); - window.electronAutoplay.dismiss(); + stop(); }; return (
{isVideo ? ( -
- + <>
- ) : ( -
-
- {payload.senderInitials} +
+
+ {payload.senderInitials} +
+

{payload.senderName}

-
-

{payload.senderName}

-

Playing audio...

+ + ) : ( + <> + +
+
+ {payload.senderInitials} +
+
+

{payload.senderName}

+

Playing audio...

+
+ )}
); diff --git a/js/src/autoplay_window/index.html b/js/src/autoplay_window/index.html index 648a97a..2dcab0c 100644 --- a/js/src/autoplay_window/index.html +++ b/js/src/autoplay_window/index.html @@ -3,9 +3,6 @@ llink - Autoplay -
diff --git a/js/src/autoplay_window/renderer.tsx b/js/src/autoplay_window/renderer.tsx index ba5267b..263518d 100644 --- a/js/src/autoplay_window/renderer.tsx +++ b/js/src/autoplay_window/renderer.tsx @@ -1,5 +1,5 @@ import { createRoot } from 'react-dom/client'; -import { AutoplayApp } from '@/autoplay/AutoplayApp'; +import { AutoplayApp } from './AutoplayApp'; import '@/styles/globals.css'; const root = createRoot(document.getElementById('root')!); diff --git a/js/src/main.ts b/js/src/main.ts index f8366b2..a513c3c 100644 --- a/js/src/main.ts +++ b/js/src/main.ts @@ -64,8 +64,6 @@ const createAutoplayWindow = () => { skipTaskbar: true, focusable: false, show: false, - transparent: true, - hasShadow: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), }, -- 2.54.0