1import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
2import { useNavigate } from "@tanstack/react-router";
3import { Volume2Icon } from "lucide-react";
4import { useState } from "react";
5
6import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
7import { Button } from "~/components/ui/button";
8import { toastManager } from "~/components/ui/toast";
9import { useActivePreviewSessions } from "~/previewStateStore";
10import { useRightPanelStore } from "~/rightPanelStore";
11import { buildThreadRouteParams } from "~/threadRoutes";
12import { previewBridge } from "./previewBridge";
13
14/** Remains visible when a playing browser is behind another panel or chat. */
15export function BrowserAudioIndicator() {
16 const sessions = useActivePreviewSessions();
17 const navigate = useNavigate();
18 const [muting, setMuting] = useState<ReadonlySet<string>>(new Set());
19 const tabs = Object.entries(sessions).flatMap(([threadKey, state]) => {
20 const threadRef = parseScopedThreadKey(threadKey);
21 if (!threadRef) return [];
22 return Object.values(state.sessions).flatMap((snapshot) => {
23 const overlay = state.desktopByTabId[snapshot.tabId];
24 if (!overlay?.hasWebContents || !overlay.audible || overlay.audioMuted) return [];
25 const title =
26 snapshot.navStatus._tag === "Idle"
27 ? "Browser"
28 : snapshot.navStatus.title || snapshot.navStatus.url;
29 return [
30 {
31 threadRef,
32 tabId: snapshot.tabId,
33 runtimeTabId: previewRuntimeTabId(threadRef, state.serverEpoch, snapshot.tabId),
34 title,
35 },
36 ];
37 });
38 });
39
40 if (tabs.length === 0) return null;
41
42 return (
43 <section
44 aria-label="Browser audio"
45 className="mb-2 space-y-2 rounded-xl border border-destructive/50 bg-[color-mix(in_srgb,var(--destructive)_15%,var(--background))] p-3 text-destructive-foreground"
46 >
47 <div role="status" className="flex items-center gap-2 font-medium">
48 <Volume2Icon aria-hidden className="size-5 shrink-0 text-destructive" />
49 Browser audio playing
50 </div>
51 {tabs.map((tab) => (
52 <div key={tab.runtimeTabId} className="flex min-w-0 items-center gap-2">
53 <span className="min-w-0 flex-1 truncate text-sm">{tab.title}</span>
54 <Button
55 variant="outline"
56 size="sm"
57 aria-label={`Open browser: ${tab.title}`}
58 onClick={() => {
59 void navigate({
60 to: "/$environmentId/$threadId",
61 params: buildThreadRouteParams(tab.threadRef),
62 }).then(() => useRightPanelStore.getState().openBrowser(tab.threadRef, tab.tabId));
63 }}
64 >
65 Open browser
66 </Button>
67 <Button
68 variant="destructive"
69 size="sm"
70 aria-label={`Mute ${tab.title}`}
71 disabled={!previewBridge || muting.has(tab.runtimeTabId)}
72 onClick={async () => {
73 if (!previewBridge || muting.has(tab.runtimeTabId)) return;
74 setMuting((current) => new Set(current).add(tab.runtimeTabId));
75 try {
76 await previewBridge.setAudioMuted(tab.runtimeTabId, true);
77 } catch {
78 toastManager.add({ type: "error", title: "Couldn’t mute browser audio" });
79 } finally {
80 setMuting((current) => {
81 const next = new Set(current);
82 next.delete(tab.runtimeTabId);
83 return next;
84 });
85 }
86 }}
87 >
88 Mute
89 </Button>
90 </div>
91 ))}
92 </section>
93 );
94}