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/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_window/AutoplayApp.tsx b/js/src/autoplay_window/AutoplayApp.tsx new file mode 100644 index 0000000..89a3ea4 --- /dev/null +++ b/js/src/autoplay_window/AutoplayApp.tsx @@ -0,0 +1,95 @@ +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
; + } + + const isVideo = payload.mimeType.startsWith('video/'); + + const handleClick = () => { + mediaRef.current?.pause(); + window.electronAutoplay.navigate({ + networkId: payload.networkId, + streamId: payload.streamId, + }); + }; + + const handleClose = (e: React.MouseEvent) => { + e.stopPropagation(); + stop(); + }; + + return ( +
+ {isVideo ? ( + <> +
+ ); +} 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..263518d --- /dev/null +++ b/js/src/autoplay_window/renderer.tsx @@ -0,0 +1,6 @@ +import { createRoot } from 'react-dom/client'; +import { AutoplayApp } from './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..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,6 +8,15 @@ declare global { maximize: () => void; fullscreen: () => void; close: () => 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; diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index 1d4e16b..4f37daa 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -4,7 +4,6 @@ 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"; @@ -71,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/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)], + }, + }, +});