enhance autoplay into desktop overlay

This commit is contained in:
talksik
2026-03-31 14:18:35 -07:00
parent aaedd0e667
commit a3d479a036
13 changed files with 207 additions and 181 deletions
+14 -1
View File
@@ -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 />} />
+76 -26
View File
@@ -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() { export function AutoplayApp() {
const handleClose = () => { const [payload, setPayload] = useState<AutoplayPayload | null>(null);
window.electronWindow.closeAutoplay();
useEffect(() => {
return window.electronAutoplay.onPlay((p) => setPayload(p));
}, []);
if (!payload) {
return <div className="h-screen w-screen" />;
}
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 ( return (
<div className="flex h-screen w-screen items-center justify-center bg-card text-card-foreground"> <div
<div className="relative w-full"> className="flex h-screen w-screen cursor-pointer overflow-hidden rounded-xl bg-card text-card-foreground shadow-lg ring-1 ring-foreground/10"
<button onClick={handleClick}
className="absolute top-2 right-2 z-10 rounded-full bg-black/50 p-1 text-white hover:bg-black/70" >
onClick={handleClose} {isVideo ? (
> <div className="relative w-full">
<X className="size-3.5" /> <button
</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}
<div className="flex items-center gap-3 px-4 py-3"> >
<div className="relative"> <X className="size-3.5" />
<div className="flex size-6 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-medium"> </button>
JD <video
key={payload.particleId}
src={payload.downloadUrl}
autoPlay
playsInline
onEnded={handleEnded}
className="w-full object-cover"
/>
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-medium">
{payload.senderInitials}
</div> </div>
<Mic className="text-primary absolute -bottom-0.5 -right-0.5 size-3" /> <p className="truncate text-xs text-muted-foreground">{payload.senderName}</p>
</div>
</div>
) : (
<div className="relative flex w-full items-center gap-2 px-3 py-3">
<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 size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-medium">
{payload.senderInitials}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">john.doe</p> <p className="truncate text-sm font-medium">{payload.senderName}</p>
<p className="text-muted-foreground truncate text-xs"> <p className="text-xs text-muted-foreground">Playing audio...</p>
Playing audio...
</p>
</div> </div>
<audio
key={payload.particleId}
src={payload.downloadUrl}
autoPlay
onEnded={handleEnded}
/>
</div> </div>
)}
<div className="bg-muted/50 mx-3 mb-3 h-1 overflow-hidden rounded-full">
<div className="bg-primary h-full w-2/3 rounded-full transition-all" />
</div>
</div>
</div> </div>
); );
} }
+3
View File
@@ -3,6 +3,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>llink - Autoplay</title> <title>llink - Autoplay</title>
<style>
html, body { background: transparent !important; margin: 0; padding: 0; overflow: hidden; }
</style>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+8 -2
View File
@@ -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,11 +8,16 @@ declare global {
maximize: () => void; maximize: () => void;
fullscreen: () => void; fullscreen: () => void;
close: () => void; close: () => void;
openAutoplay: () => void;
closeAutoplay: () => void;
openHuddle: () => void; openHuddle: () => void;
closeHuddle: () => 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>;
openExternal: (url: string) => Promise<void>; openExternal: (url: string) => Promise<void>;
+1 -8
View File
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { List, LayoutGrid } from "lucide-react"; 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";
@@ -25,11 +24,6 @@ export default function NetworkRoot() {
const viewMode = useViewModeStore((s) => s.viewMode); const viewMode = useViewModeStore((s) => s.viewMode);
const setViewMode = useViewModeStore((s) => s.setViewMode); const setViewMode = useViewModeStore((s) => s.setViewMode);
useEffect(() => {
window.electronWindow.openAutoplay();
window.electronWindow.openHuddle();
}, []);
const { streams } = useStreamParticles(path); const { streams } = useStreamParticles(path);
const userId = useAuthStore((s) => s.user?.id); const userId = useAuthStore((s) => s.user?.id);
useDockBadge(streams, userId); useDockBadge(streams, userId);
@@ -76,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,
+1 -1
View File
@@ -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,
+27 -2
View File
@@ -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
} }
+10
View File
@@ -0,0 +1,10 @@
export interface AutoplayPayload {
particleId: string;
streamId: string;
networkId: string;
downloadUrl: string;
mimeType: string;
durationMs: number;
senderName: string;
senderInitials: string;
}
+47 -16
View File
@@ -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,12 +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 autoplayWindow: BrowserWindow | null = null;
let huddleWindow: 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,
@@ -38,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();
@@ -47,21 +46,26 @@ 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 = () => { const createAutoplayWindow = () => {
if (autoplayWindow) { if (autoplayWindow) return;
autoplayWindow.focus();
return;
}
autoplayWindow = new BrowserWindow({ autoplayWindow = new BrowserWindow({
width: 300, width: 320,
height: 100, height: 88,
resizable: false, resizable: false,
frame: false, frame: false,
alwaysOnTop: true, alwaysOnTop: true,
skipTaskbar: true, skipTaskbar: true,
focusable: false,
show: false,
transparent: true,
hasShadow: false,
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.js'), 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 // Window control IPC handlers
ipcMain.on('window:minimize', (event) => { ipcMain.on('window:minimize', (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize(); BrowserWindow.fromWebContents(event.sender)?.minimize();
@@ -132,12 +143,6 @@ ipcMain.on('window:fullscreen', (event) => {
}); });
// Secondary window IPC handlers // Secondary window IPC handlers
ipcMain.on('window:open-autoplay', () => {
createAutoplayWindow();
});
ipcMain.on('window:close-autoplay', () => {
autoplayWindow?.close();
});
ipcMain.on('window:open-huddle', () => { ipcMain.on('window:open-huddle', () => {
createHuddleWindow(); createHuddleWindow();
}); });
@@ -145,6 +150,31 @@ ipcMain.on('window:close-huddle', () => {
huddleWindow?.close(); 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>();
@@ -291,6 +321,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
+16 -2
View File
@@ -7,12 +7,26 @@ 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'),
openAutoplay: () => ipcRenderer.send('window:open-autoplay'),
closeAutoplay: () => ipcRenderer.send('window:close-autoplay'),
openHuddle: () => ipcRenderer.send('window:open-huddle'), openHuddle: () => ipcRenderer.send('window:open-huddle'),
closeHuddle: () => ipcRenderer.send('window:close-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', {
fetchMetadata: (url: string) => ipcRenderer.invoke('link:fetch-metadata', url), fetchMetadata: (url: string) => ipcRenderer.invoke('link:fetch-metadata', url),
openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url), openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url),
+3 -27
View File
@@ -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);
}
}, },
})); }));