From a3d479a036b54a0ee7f8dc38571acb6d04b8729c Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 31 Mar 2026 14:18:35 -0700 Subject: [PATCH] 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); }, }));