setup electron multi-window architecture #102
@@ -68,6 +68,14 @@ const config: ForgeConfig = {
|
|||||||
name: 'main_window',
|
name: 'main_window',
|
||||||
config: 'vite.renderer.config.mts',
|
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
|
// Fuses are used to enable/disable various Electron functionality
|
||||||
|
|||||||
+14
-1
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
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 { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { LoginPage } from "@/features/auth/login-page";
|
import { LoginPage } from "@/features/auth/login-page";
|
||||||
@@ -42,9 +42,22 @@ const App = () => {
|
|||||||
return <AuthenticatedApp />;
|
return <AuthenticatedApp />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function AutoplayNavigationListener() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return window.electronAutoplay.onNavigate((data) => {
|
||||||
|
navigate(`/${data.networkId}/${data.streamId}`);
|
||||||
|
});
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function AuthenticatedApp() {
|
function AuthenticatedApp() {
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
|
<AutoplayNavigationListener />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
|
||||||
|
|||||||
@@ -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<AutoplayPayload | null>(null);
|
||||||
|
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return window.electronAutoplay.onPlay((p) => setPayload(p));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
mediaRef.current?.pause();
|
||||||
|
setPayload(null);
|
||||||
|
window.electronAutoplay.dismiss();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return <div className="h-screen w-screen" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="relative h-screen w-screen cursor-pointer overflow-hidden bg-black"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{isVideo ? (
|
||||||
|
<>
|
||||||
|
<video
|
||||||
|
ref={mediaRef as React.Ref<HTMLVideoElement>}
|
||||||
|
key={payload.particleId}
|
||||||
|
src={payload.downloadUrl}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
onEnded={stop}
|
||||||
|
className="block h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 bg-gradient-to-t from-black/60 to-transparent px-3 py-2">
|
||||||
|
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/20 text-[10px] font-medium text-white">
|
||||||
|
{payload.senderInitials}
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-xs text-white/80">{payload.senderName}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<div className="flex h-full w-full items-center gap-2 bg-card px-3 py-3">
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
||||||
|
{payload.senderInitials}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-card-foreground">{payload.senderName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Playing audio...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<audio
|
||||||
|
ref={mediaRef as React.Ref<HTMLAudioElement>}
|
||||||
|
key={payload.particleId}
|
||||||
|
src={payload.downloadUrl}
|
||||||
|
autoPlay
|
||||||
|
onEnded={stop}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>llink - Autoplay</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./renderer.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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(<AutoplayApp />);
|
||||||
Vendored
+10
@@ -1,4 +1,5 @@
|
|||||||
import type { LinkMetadata } from './lib/link-metadata';
|
import type { LinkMetadata } from './lib/link-metadata';
|
||||||
|
import type { AutoplayPayload } from './lib/autoplay-ipc';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -7,6 +8,15 @@ declare global {
|
|||||||
maximize: () => void;
|
maximize: () => void;
|
||||||
fullscreen: () => void;
|
fullscreen: () => void;
|
||||||
close: () => 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: {
|
electronLink: {
|
||||||
fetchMetadata: (url: string) => Promise<LinkMetadata | null>;
|
fetchMetadata: (url: string) => Promise<LinkMetadata | null>;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { List, LayoutGrid } from "lucide-react";
|
|||||||
import { particlePath } from "@/lib/particle-path";
|
import { particlePath } from "@/lib/particle-path";
|
||||||
import { ParticleListView } from "@/features/particles/particle-list-view";
|
import { ParticleListView } from "@/features/particles/particle-list-view";
|
||||||
import { ParticleGridView } from "@/features/particles/particle-grid-view";
|
import { ParticleGridView } from "@/features/particles/particle-grid-view";
|
||||||
import { AutoplayOverlay } from "@/features/particles/autoplay-overlay";
|
|
||||||
import ControlsIndicator from "@/features/compose/controls-indicator";
|
import ControlsIndicator from "@/features/compose/controls-indicator";
|
||||||
import { ComposeOverlay } from "./compose/compose-overlay";
|
import { ComposeOverlay } from "./compose/compose-overlay";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
@@ -71,7 +70,6 @@ export default function NetworkRoot() {
|
|||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AutoplayOverlay networkId={networkId!} />
|
|
||||||
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-3">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-3">
|
||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
|
|||||||
@@ -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 = (
|
|
||||||
<button
|
|
||||||
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
stop();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const senderBar = (
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
|
||||||
<Avatar size="sm">
|
|
||||||
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
|
|
||||||
{senderInitials}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<Small className="truncate text-muted-foreground">{senderName}</Small>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="absolute bottom-4 right-4 z-40 w-52 cursor-pointer overflow-hidden rounded-xl bg-card text-card-foreground shadow-lg ring-1 ring-foreground/10"
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
{isVideo ? (
|
|
||||||
<div className="relative">
|
|
||||||
{closeButton}
|
|
||||||
<video
|
|
||||||
src={url}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
onEnded={stop}
|
|
||||||
className="w-full object-cover"
|
|
||||||
/>
|
|
||||||
{senderBar}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="relative">
|
|
||||||
{closeButton}
|
|
||||||
<div className="flex items-center gap-2 px-3 py-3">
|
|
||||||
<Avatar size="sm">
|
|
||||||
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
|
|
||||||
{senderInitials}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Small className="truncate font-medium">{senderName}</Small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<audio src={url} autoPlay onEnded={stop} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -94,7 +94,7 @@ function StreamRow({
|
|||||||
const userId = user?.id ?? "";
|
const userId = user?.id ?? "";
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
|
|
||||||
useStreamAutoplay(latestChild, particle);
|
useStreamAutoplay(latestChild, particle, networkId, network);
|
||||||
|
|
||||||
const expiringSoon = useExpiringSoon(
|
const expiringSoon = useExpiringSoon(
|
||||||
particle.last_child_created_at,
|
particle.last_child_created_at,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function S
|
|||||||
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
|
|
||||||
useStreamAutoplay(latestChild, particle);
|
useStreamAutoplay(latestChild, particle, networkId, network);
|
||||||
|
|
||||||
const expiringSoon = useExpiringSoon(
|
const expiringSoon = useExpiringSoon(
|
||||||
particle.last_child_created_at,
|
particle.last_child_created_at,
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import beepSound from "../../assets/sound.wav";
|
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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAutoplayStore } from "@/stores/autoplay-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.
|
* 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.
|
* Plays a beep sound for new text particles from other users.
|
||||||
|
* Sends autoplay data to the separate autoplay window via IPC.
|
||||||
*/
|
*/
|
||||||
export function useStreamAutoplay(
|
export function useStreamAutoplay(
|
||||||
latestChild: Particle | null,
|
latestChild: Particle | null,
|
||||||
streamParticle: Particle & { type: "stream"; properties: StreamProperties },
|
streamParticle: Particle & { type: "stream"; properties: StreamProperties },
|
||||||
|
networkId: string,
|
||||||
|
network: Network | undefined,
|
||||||
) {
|
) {
|
||||||
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
||||||
const settledIdRef = useRef<string | undefined>(undefined);
|
const settledIdRef = useRef<string | undefined>(undefined);
|
||||||
@@ -36,6 +41,26 @@ export function useStreamAutoplay(
|
|||||||
|
|
||||||
if (latestChild.type !== "media") return;
|
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
|
}, [latestChild?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||||
|
{/* Video grid */}
|
||||||
|
<div className="flex flex-1 items-center justify-center gap-4 p-6">
|
||||||
|
<div className="bg-muted flex aspect-video w-full max-w-md items-center justify-center rounded-xl">
|
||||||
|
<span className="text-muted-foreground text-sm">You</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted flex aspect-video w-full max-w-md items-center justify-center rounded-xl">
|
||||||
|
<span className="text-muted-foreground text-sm">Participant</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls bar */}
|
||||||
|
<div className="flex items-center justify-center gap-3 border-t px-6 py-4">
|
||||||
|
<button
|
||||||
|
className="rounded-full bg-muted p-3 hover:bg-muted/80 transition-colors"
|
||||||
|
onClick={() => setMicOn(!micOn)}
|
||||||
|
>
|
||||||
|
{micOn ? <Mic className="size-5" /> : <MicOff className="size-5 text-destructive" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-full bg-muted p-3 hover:bg-muted/80 transition-colors"
|
||||||
|
onClick={() => setVideoOn(!videoOn)}
|
||||||
|
>
|
||||||
|
{videoOn ? <Video className="size-5" /> : <VideoOff className="size-5 text-destructive" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-full bg-destructive p-3 text-destructive-foreground hover:bg-destructive/90 transition-colors"
|
||||||
|
onClick={handleLeave}
|
||||||
|
>
|
||||||
|
<PhoneOff className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>llink - Huddle</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./renderer.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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(<HuddleApp />);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export interface AutoplayPayload {
|
||||||
|
particleId: string;
|
||||||
|
streamId: string;
|
||||||
|
networkId: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
mimeType: string;
|
||||||
|
durationMs: number;
|
||||||
|
senderName: string;
|
||||||
|
senderInitials: string;
|
||||||
|
}
|
||||||
+112
-4
@@ -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 path from 'node:path';
|
||||||
import started from 'electron-squirrel-startup';
|
import started from 'electron-squirrel-startup';
|
||||||
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
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'));
|
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 = () => {
|
const createWindow = () => {
|
||||||
// Create the browser window.
|
mainWindow = new BrowserWindow({
|
||||||
const mainWindow = new BrowserWindow({
|
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600,
|
height: 600,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
@@ -35,7 +38,6 @@ const createWindow = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// and load the index.html of the app.
|
|
||||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||||
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
@@ -44,8 +46,80 @@ const createWindow = () => {
|
|||||||
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`),
|
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
|
// Window control IPC handlers
|
||||||
ipcMain.on('window:minimize', (event) => {
|
ipcMain.on('window:minimize', (event) => {
|
||||||
BrowserWindow.fromWebContents(event.sender)?.minimize();
|
BrowserWindow.fromWebContents(event.sender)?.minimize();
|
||||||
@@ -66,6 +140,39 @@ ipcMain.on('window:fullscreen', (event) => {
|
|||||||
if (win) win.setFullScreen(!win.isFullScreen());
|
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 ---
|
// --- Link metadata ---
|
||||||
|
|
||||||
const metadataCache = new Map<string, LinkMetadata>();
|
const metadataCache = new Map<string, LinkMetadata>();
|
||||||
@@ -212,6 +319,7 @@ app.on('ready', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
createWindow();
|
createWindow();
|
||||||
|
createAutoplayWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Quit when all windows are closed, except on macOS. There, it's common
|
// Quit when all windows are closed, except on macOS. There, it's common
|
||||||
|
|||||||
@@ -7,6 +7,24 @@ contextBridge.exposeInMainWorld('electronWindow', {
|
|||||||
maximize: () => ipcRenderer.send('window:maximize'),
|
maximize: () => ipcRenderer.send('window:maximize'),
|
||||||
fullscreen: () => ipcRenderer.send('window:fullscreen'),
|
fullscreen: () => ipcRenderer.send('window:fullscreen'),
|
||||||
close: () => ipcRenderer.send('window:close'),
|
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', {
|
contextBridge.exposeInMainWorld('electronLink', {
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Particle } from "@/api/types";
|
|
||||||
|
|
||||||
type MediaParticle = Extract<Particle, { type: "media" }>;
|
|
||||||
|
|
||||||
const STORAGE_KEY = "llink:autoplay-muted";
|
const STORAGE_KEY = "llink:autoplay-muted";
|
||||||
|
|
||||||
@@ -22,38 +19,17 @@ function saveMuted(muted: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AutoplayState {
|
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 */
|
/** When true, incoming play requests are ignored */
|
||||||
muted: boolean;
|
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 */
|
/** Toggle muted state */
|
||||||
toggleMuted: () => void;
|
toggleMuted: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAutoplayStore = create<AutoplayState>((set, get) => ({
|
export const useAutoplayStore = create<AutoplayState>((set, get) => ({
|
||||||
activeParticle: null,
|
|
||||||
streamId: null,
|
|
||||||
muted: loadMuted(),
|
muted: loadMuted(),
|
||||||
play: (particle, streamId) => {
|
|
||||||
if (get().muted) return;
|
|
||||||
set({ activeParticle: particle, streamId });
|
|
||||||
},
|
|
||||||
stop: () => set({ activeParticle: null, streamId: null }),
|
|
||||||
toggleMuted: () => {
|
toggleMuted: () => {
|
||||||
const wasMuted = get().muted;
|
const next = !get().muted;
|
||||||
// If muting, also stop any current playback
|
set({ muted: next });
|
||||||
if (!wasMuted) {
|
saveMuted(next);
|
||||||
set({ muted: true, activeParticle: null, streamId: null });
|
|
||||||
saveMuted(true);
|
|
||||||
} else {
|
|
||||||
set({ muted: false });
|
|
||||||
saveMuted(false);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Vendored
+8
@@ -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;
|
||||||
@@ -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)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user