fix(desktop): mute a hidden session's kept Browser and restore its sound on return
A hidden session's live Browser stays mounted so returning shows the same page, but it kept playing audio into the chat the user had switched to. The parked-pane set is now an atom; a preview pane whose tab is parked mutes its guest (webview.setAudioMuted) and unmutes on return only if it was the one that muted it, so a page muted beforehand stays muted. Inactive tabs in the session on screen keep playing, like background tabs in a browser. Timers/rendering: a CSS-hidden webview still reports visible to Chromium and Electron has no public API to mark a guest hidden without detaching it (any detach destroys the guest, losing its state), so throttling is left as is.
This commit is contained in:
@@ -7,14 +7,25 @@
|
||||
* moves a guest's focus when its `<webview>` is hidden), so main's
|
||||
* focused-guest gestures (mouse back/forward, swipe, ⌘R) would navigate it.
|
||||
* Only this renderer knows what is on screen, so it tells main.
|
||||
*
|
||||
* A hidden SESSION's page (parked, see parked-panes.ts) keeps running — that
|
||||
* is the point of keeping it — but must not be heard from a chat the user has
|
||||
* left. It is muted while parked and handed back as it was. An inactive tab in
|
||||
* the session on screen keeps playing, like a background tab in any browser.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type RefObject, useEffect } from 'react'
|
||||
|
||||
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||
import { $parkedTreePanes } from '@/components/pane-shell/tree/parked-panes'
|
||||
import { PREVIEW_TILE_PREFIX } from '@/store/preview-explicit'
|
||||
|
||||
export interface OffscreenGuest {
|
||||
getWebContentsId?: () => number
|
||||
isConnected?: boolean
|
||||
isAudioMuted?: () => boolean
|
||||
setAudioMuted?: (muted: boolean) => void
|
||||
}
|
||||
|
||||
/** The guest's webContents id, or null before attach / after removal (Electron
|
||||
@@ -27,8 +38,27 @@ function guestId(webview: null | OffscreenGuest): null | number {
|
||||
}
|
||||
}
|
||||
|
||||
export function usePreviewGuestOffscreen(webviewRef: RefObject<null | OffscreenGuest>): void {
|
||||
/** Mute `webview` unless it already is; returns whether this call muted it, so
|
||||
* the caller never unmutes a page that was muted before. */
|
||||
function muteGuest(webview: OffscreenGuest): boolean {
|
||||
try {
|
||||
if (webview.isAudioMuted?.()) {
|
||||
return false
|
||||
}
|
||||
|
||||
webview.setAudioMuted?.(true)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// Not attached yet / already gone: nothing is playing.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function usePreviewGuestOffscreen(webviewRef: RefObject<null | OffscreenGuest>, tabId?: string): void {
|
||||
const hidden = !usePaneVisible()
|
||||
const parkedPanes = useStore($parkedTreePanes)
|
||||
const parked = Boolean(tabId) && parkedPanes.has(`${PREVIEW_TILE_PREFIX}:${tabId}`)
|
||||
|
||||
useEffect(() => {
|
||||
const id = guestId(webviewRef.current)
|
||||
@@ -37,4 +67,26 @@ export function usePreviewGuestOffscreen(webviewRef: RefObject<null | OffscreenG
|
||||
window.hermesDesktop?.setPreviewGuestHidden?.(id, hidden)
|
||||
}
|
||||
}, [hidden, webviewRef])
|
||||
|
||||
useEffect(() => {
|
||||
const webview = webviewRef.current
|
||||
|
||||
if (!parked || !webview || !muteGuest(webview)) {
|
||||
return
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Unmounted while parked (closed, or let go past the retention cap):
|
||||
// the guest is being destroyed, and unmuting it would only blip.
|
||||
if (!webview.isConnected) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
webview.setAudioMuted?.(false)
|
||||
} catch {
|
||||
// The guest went away while parked; nothing to restore.
|
||||
}
|
||||
}
|
||||
}, [parked, webviewRef])
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { onComposerAttachImagesRequest } from '@/app/chat/composer/focus'
|
||||
import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility'
|
||||
import { setTreePaneParked } from '@/components/pane-shell/tree/parked-panes'
|
||||
import { $previewTabs, closeRightRail, openPreview, previewTabId } from '@/store/preview'
|
||||
import { $connection, $selectedStoredSessionId } from '@/store/session'
|
||||
|
||||
@@ -943,6 +944,45 @@ describe('PreviewPane off-screen guest', () => {
|
||||
})
|
||||
expect(setPreviewGuestHidden).toHaveBeenLastCalledWith(41, false)
|
||||
})
|
||||
|
||||
// A hidden session's kept page keeps running, but is not heard from the chat
|
||||
// the user switched to; it comes back with the sound it had.
|
||||
async function renderAudibleGuest(tabId: string, mutedBefore: boolean) {
|
||||
desktopWindow.hermesDesktop = {} as unknown as Window['hermesDesktop']
|
||||
let rendered!: ReturnType<typeof render>
|
||||
|
||||
await act(async () => {
|
||||
rendered = render(<PreviewPane tabId={tabId} target={target} />)
|
||||
})
|
||||
|
||||
let muted = mutedBefore
|
||||
const setAudioMuted = vi.fn((next: boolean) => (muted = next))
|
||||
|
||||
Object.assign(rendered.container.querySelector('webview')!, { isAudioMuted: () => muted, setAudioMuted })
|
||||
|
||||
return { isMuted: () => muted, setAudioMuted }
|
||||
}
|
||||
|
||||
it("mutes a hidden session's page and restores its sound on return", async () => {
|
||||
const { isMuted, setAudioMuted } = await renderAudibleGuest('url:audible', false)
|
||||
|
||||
act(() => setTreePaneParked('preview-tile:url:audible', true))
|
||||
expect(isMuted()).toBe(true)
|
||||
|
||||
act(() => setTreePaneParked('preview-tile:url:audible', false))
|
||||
expect(isMuted()).toBe(false)
|
||||
expect(setAudioMuted.mock.calls).toEqual([[true], [false]])
|
||||
})
|
||||
|
||||
it('never unmutes a page that was already muted before its session left', async () => {
|
||||
const { isMuted, setAudioMuted } = await renderAudibleGuest('url:muted', true)
|
||||
|
||||
act(() => setTreePaneParked('preview-tile:url:muted', true))
|
||||
act(() => setTreePaneParked('preview-tile:url:muted', false))
|
||||
|
||||
expect(isMuted()).toBe(true)
|
||||
expect(setAudioMuted).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PreviewPane local HTML Render|Source toggle', () => {
|
||||
|
||||
@@ -276,7 +276,7 @@ export function PreviewPane({
|
||||
const lastRestartEventRef = useRef('')
|
||||
const previewContentRef = useRef<HTMLDivElement | null>(null)
|
||||
const webviewRef = useRef<PreviewWebview | null>(null)
|
||||
usePreviewGuestOffscreen(webviewRef)
|
||||
usePreviewGuestOffscreen(webviewRef, tabId)
|
||||
const previewServerRestart = useStore($previewServerRestart)
|
||||
const consoleHeight = useStore(consoleState.$height)
|
||||
const consoleOpen = useStore(consoleState.$open)
|
||||
|
||||
@@ -5,19 +5,32 @@
|
||||
* — while adoption must not dock it back. The contribution's owner parks and
|
||||
* unparks it: a preview tab whose session is not on screen.
|
||||
*
|
||||
* An atom so a parked body can react to being parked (a Browser mutes its
|
||||
* page while its session is off screen).
|
||||
*
|
||||
* Memory-only: after a relaunch there is no live body left to keep.
|
||||
*/
|
||||
|
||||
const parked = new Set<string>()
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
export const $parkedTreePanes = atom<ReadonlySet<string>>(new Set())
|
||||
|
||||
export function isTreePaneParked(paneId: string): boolean {
|
||||
return parked.has(paneId)
|
||||
return $parkedTreePanes.get().has(paneId)
|
||||
}
|
||||
|
||||
export function setTreePaneParked(paneId: string, isParked: boolean): void {
|
||||
if (isParked) {
|
||||
parked.add(paneId)
|
||||
} else {
|
||||
parked.delete(paneId)
|
||||
if (isTreePaneParked(paneId) === isParked) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = new Set($parkedTreePanes.get())
|
||||
|
||||
if (isParked) {
|
||||
next.add(paneId)
|
||||
} else {
|
||||
next.delete(paneId)
|
||||
}
|
||||
|
||||
$parkedTreePanes.set(next)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user