From 51775ac49f8c9ac80d41cf91d0564b3f0a9ce7c2 Mon Sep 17 00:00:00 2001 From: Oliver Blanthorn Date: Sat, 25 Jul 2026 08:34:29 +0200 Subject: [PATCH] Fix #5049: `ga` picks last audible tab Also, add tri.webext.getLastAudibleTab() for use in scripts --- readme.md | 2 +- src/excmds.test.ts | 21 +++++++++++++++++++++ src/excmds.ts | 12 ++++++------ src/lib/webext.ts | 21 +++++++++++++++++++++ 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/readme.md b/readme.md index 8246a736..4db98f56 100644 --- a/readme.md +++ b/readme.md @@ -161,7 +161,7 @@ If you want to use Firefox's default `` binding to open the bookmarks sideb - `u` — undo the last tab/window closure - `gt`/`gT` — go to the next/previous tab - `g^ OR g0`/`g$` — go to the first/last tab -- `ga` — go to the tab currently playing audio +- `ga` — go to the tab currently playing audio, or the one that most recently stopped - `` — go to the last active tab - `b` — bring up a list of open tabs in the current window; you can type the tab ID or part of the title or URL to choose a tab diff --git a/src/excmds.test.ts b/src/excmds.test.ts index cec6be43..2b008bf5 100644 --- a/src/excmds.test.ts +++ b/src/excmds.test.ts @@ -1,4 +1,5 @@ import { queryAndURLwrangler } from "@src/lib/webext" +import * as webext from "@src/lib/webext" import * as config from "@src/lib/config" import * as Native from "@src/lib/native" @@ -49,6 +50,7 @@ Object.defineProperty(browser, "sessions", { value: { getTabValue: jest.fn(), setTabValue: jest.fn() }, }) +webext.initLastAudibleTabTracking() const backgroundExcmds = require("@src/.excmds_background.generated") const { jsb, nativeopen, quickmarkremove, set, tabopen, winopen } = backgroundExcmds @@ -138,6 +140,25 @@ test("`winopen` creates a neutral tab before navigating it", async () => { }) }) +test("`getLastAudibleTab` prioritises current audio, falls back, and forgets closed tabs", async () => { + const currentTab = { id: 1, windowId: 10 } as browser.tabs.Tab + const previousTab = { id: 2, windowId: 20 } as browser.tabs.Tab + const onUpdated = browser.tabs.onUpdated.addListener as jest.Mock + const onRemoved = browser.tabs.onRemoved.addListener as jest.Mock + onUpdated.mock.calls[0][0](previousTab.id, { audible: false }, previousTab) + jest.mocked(browser.tabs.query).mockResolvedValue([]) + jest.mocked(browser.tabs.query).mockResolvedValueOnce([currentTab]) + await expect(webext.getLastAudibleTab()).resolves.toBe(currentTab) + jest.mocked(browser.tabs.get).mockResolvedValueOnce(previousTab) + await expect(webext.getLastAudibleTab()).resolves.toBe(previousTab) + jest.mocked(browser.tabs.get).mockRejectedValueOnce(new Error()) + await expect(webext.getLastAudibleTab()).resolves.toBeUndefined() + onRemoved.mock.calls[0][0](previousTab.id) + jest.mocked(browser.tabs.get).mockClear() + await webext.getLastAudibleTab() + expect(browser.tabs.get).not.toHaveBeenCalled() +}) + test("`nativeopen` targets the running macOS Firefox application", async () => { jest.mocked(browser.runtime.getPlatformInfo).mockResolvedValue({ arch: "x86-64", diff --git a/src/excmds.ts b/src/excmds.ts index 9d904bd7..3ab6ab3b 100644 --- a/src/excmds.ts +++ b/src/excmds.ts @@ -76,7 +76,7 @@ // Shared import * as Messaging from "@src/lib/messaging" -import { ownWinTriIndex, getTriVersion, getTriVersionName, browserBg, activeTab, activeTabOnWindow, activeTabId, activeTabContainerId, openInNewTab, openInNewWindow, openInTab, queryAndURLwrangler, goToTab, getSortedTabs, prevActiveTab } from "@src/lib/webext" +import { ownWinTriIndex, getTriVersion, getTriVersionName, browserBg, activeTab, activeTabOnWindow, activeTabId, activeTabContainerId, openInNewTab, openInNewWindow, openInTab, queryAndURLwrangler, goToTab, getSortedTabs, prevActiveTab, getLastAudibleTab } from "@src/lib/webext" import * as Container from "@src/lib/containers" import state from "@src/state" import * as State from "@src/state" @@ -2821,13 +2821,13 @@ export async function tabpush(windowId?: number) { } } -/** Switch to the tab currently playing audio, if any. */ +/** Switch to a tab playing audio, or the one that most recently stopped. */ //#background export async function tabaudio() { - const tabs = await browser.tabs.query({ audible: true }) - if (tabs.length > 0) { - await browser.windows.update(tabs[0].windowId, { focused: true }) - return browser.tabs.update(tabs[0].id, { active: true }) + const tab = await getLastAudibleTab() + if (tab) { + await browser.windows.update(tab.windowId, { focused: true }) + return browser.tabs.update(tab.id, { active: true }) } } diff --git a/src/lib/webext.ts b/src/lib/webext.ts index ba1c0992..73d53eb4 100644 --- a/src/lib/webext.ts +++ b/src/lib/webext.ts @@ -72,6 +72,27 @@ export function getContext() { // Make this library work for both content and background. export const browserBg = inContentScript() ? browserProxy : browser +let lastAudibleTabId: number | undefined + +export function initLastAudibleTabTracking() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + if (changeInfo.audible === false) lastAudibleTabId = tabId + }, + { properties: ["audible"] }, + ) + browser.tabs.onRemoved.addListener(tabId => { + if (tabId === lastAudibleTabId) lastAudibleTabId = undefined + }) +} +if (getContext() === "background") initLastAudibleTabTracking() +/** Return a currently audible tab, or the one that most recently stopped. */ +export async function getLastAudibleTab() { + const [tab] = await browserBg.tabs.query({ audible: true }) + if (tab || lastAudibleTabId === undefined) return tab + return browserBg.tabs.get(lastAudibleTabId).catch(() => undefined) +} + /** The first active tab in the currentWindow. * * TODO: Highlander theory: Can there ever be more than one?