| 1 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 2 | import { useNavigate } from "@tanstack/react-router"; |
| 3 | import { Volume2Icon } from "lucide-react"; |
| 4 | import { useState } from "react"; |
| 5 | |
| 6 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; |
| 7 | import { Button } from "~/components/ui/button"; |
| 8 | import { toastManager } from "~/components/ui/toast"; |
| 9 | import { useActivePreviewSessions } from "~/previewStateStore"; |
| 10 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 11 | import { buildThreadRouteParams } from "~/threadRoutes"; |
| 12 | import { previewBridge } from "./previewBridge"; |
| 13 | |
| 14 | /** Remains visible when a playing browser is behind another panel or chat. */ |
| 15 | export 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 | } |