Skip to content

Commit 058a796

Browse files
authored
feat: add audio tracking for background tabs (#609)
* feat: add audio tracking for background tabs - Add AudioTabListener to track audio playback in inactive tabs - Track tab state changes during playback to record only background time - Prevent double counting by skipping active tab segments - Add onTabRemoved wrapper to chrome tab API When users play media (e.g., YouTube) and switch to another tab, the audio continues playing in the background. This feature tracks that background playback time separately from active tab time tracked by content scripts. * feat: add state persistence for Chrome/Edge service worker support - Store audio playback state in chrome.storage.local every 5 seconds - Restore state on service worker restart - Ensures tracking survives service worker termination in Manifest V3 browsers - Minimal overhead: only persists when tabs are actively playing audio Tested on Chrome/Iron with manual service worker termination.
1 parent 4873d90 commit 058a796

File tree

3 files changed

+209
-1
lines changed

3 files changed

+209
-1
lines changed

src/api/chrome/tab.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,10 @@ export function onTabUpdated(handler: TabHandler<ChromeTabChangeInfo>): void {
107107
handler(tabId, changeInfo, tab)
108108
})
109109
}
110+
111+
export function onTabRemoved(handler: (tabId: number) => void): void {
112+
chrome.tabs.onRemoved.addListener((tabId: number) => {
113+
handleError("tabRemoved")
114+
handler(tabId)
115+
})
116+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* Copyright (c) 2021 Hengyang Zhang
3+
*
4+
* This software is released under the MIT License.
5+
* https://opensource.org/licenses/MIT
6+
*/
7+
import { onTabRemoved, onTabUpdated } from "@api/chrome/tab"
8+
import { extractHostname, type HostInfo } from "@util/pattern"
9+
10+
type AudioState = {
11+
host: string
12+
url: string
13+
tabId: number
14+
lastCheckTime: number
15+
isCurrentlyActive: boolean
16+
}
17+
18+
type AudioEventParam = {
19+
url: string
20+
tabId: number
21+
host: string
22+
duration: number // seconds of audio playback
23+
}
24+
25+
type AudioEventHandler = (params: AudioEventParam) => void
26+
27+
const STORAGE_KEY = 'audioPlayingTabsState'
28+
const PERSIST_INTERVAL_MS = 5000 // Save state every 5 seconds
29+
30+
export default class AudioTabListener {
31+
private listeners: AudioEventHandler[] = []
32+
private audioPlayingTabs: Map<number, AudioState> = new Map()
33+
private persistenceInterval: number | null = null
34+
35+
register(handler: AudioEventHandler): AudioTabListener {
36+
this.listeners.push(handler)
37+
return this
38+
}
39+
40+
private notifyListeners(param: AudioEventParam) {
41+
this.listeners.forEach(func => func(param))
42+
}
43+
44+
private handleAudioStart(tabId: number, url: string, isActiveTab: boolean) {
45+
if (!url) return
46+
47+
const hostInfo: HostInfo = extractHostname(url)
48+
const host: string = hostInfo.host
49+
50+
const existingState = this.audioPlayingTabs.get(tabId)
51+
52+
if (existingState && existingState.url === url) {
53+
return
54+
}
55+
56+
this.audioPlayingTabs.set(tabId, {
57+
host,
58+
url,
59+
tabId,
60+
lastCheckTime: Date.now(),
61+
isCurrentlyActive: isActiveTab
62+
})
63+
}
64+
65+
private handleAudioStop(tabId: number) {
66+
const state = this.audioPlayingTabs.get(tabId)
67+
if (!state) return
68+
69+
// Record any remaining time before stopping
70+
this.recordSegment(state)
71+
72+
this.audioPlayingTabs.delete(tabId)
73+
}
74+
75+
private recordSegment(state: AudioState) {
76+
const now = Date.now()
77+
const duration = Math.floor((now - state.lastCheckTime) / 1000)
78+
79+
// Only record if tab was INACTIVE during this segment
80+
if (duration > 0 && !state.isCurrentlyActive) {
81+
this.notifyListeners({
82+
url: state.url,
83+
tabId: state.tabId,
84+
host: state.host,
85+
duration
86+
})
87+
}
88+
89+
// Reset check time for next segment
90+
state.lastCheckTime = now
91+
}
92+
93+
private handleTabActivationChange(tabId: number, isNowActive: boolean) {
94+
const state = this.audioPlayingTabs.get(tabId)
95+
if (!state) return
96+
97+
// If state changed, record the previous segment
98+
if (state.isCurrentlyActive !== isNowActive) {
99+
this.recordSegment(state)
100+
state.isCurrentlyActive = isNowActive
101+
}
102+
}
103+
104+
private async restoreState() {
105+
try {
106+
const result = await chrome.storage.local.get(STORAGE_KEY)
107+
const stateArray = result[STORAGE_KEY]
108+
if (stateArray && Array.isArray(stateArray)) {
109+
this.audioPlayingTabs = new Map(stateArray)
110+
}
111+
} catch (e) {
112+
// Ignore errors - state will start fresh
113+
console.warn('Failed to restore audio tracking state:', e)
114+
}
115+
}
116+
117+
private async saveState() {
118+
// Don't save if no tabs are playing audio
119+
if (this.audioPlayingTabs.size === 0) return
120+
121+
try {
122+
const stateArray = Array.from(this.audioPlayingTabs.entries())
123+
await chrome.storage.local.set({ [STORAGE_KEY]: stateArray })
124+
} catch (e) {
125+
// Ignore errors - will retry on next interval
126+
console.warn('Failed to save audio tracking state:', e)
127+
}
128+
}
129+
130+
private startPersistence() {
131+
// Persist state periodically to survive service worker restarts (Chrome/Edge)
132+
this.persistenceInterval = setInterval(() => {
133+
this.saveState()
134+
}, PERSIST_INTERVAL_MS) as unknown as number
135+
}
136+
137+
private stopPersistence() {
138+
if (this.persistenceInterval !== null) {
139+
clearInterval(this.persistenceInterval)
140+
this.persistenceInterval = null
141+
}
142+
}
143+
144+
async listen(getActiveTabId: () => number | null) {
145+
// Restore state on startup (handles Chrome service worker restarts)
146+
await this.restoreState()
147+
148+
// Start periodic persistence
149+
this.startPersistence()
150+
151+
onTabUpdated((tabId, changeInfo, tab) => {
152+
if (changeInfo.audible !== undefined) {
153+
if (changeInfo.audible && tab?.url) {
154+
const activeTabId = getActiveTabId()
155+
const isActive = tabId === activeTabId
156+
this.handleAudioStart(tabId, tab.url, isActive)
157+
} else {
158+
this.handleAudioStop(tabId)
159+
}
160+
}
161+
})
162+
163+
onTabRemoved(tabId => {
164+
this.handleAudioStop(tabId)
165+
})
166+
}
167+
168+
onActiveTabChanged(newActiveTabId: number | null) {
169+
this.audioPlayingTabs.forEach((state, tabId) => {
170+
const isNowActive = tabId === newActiveTabId
171+
this.handleTabActivationChange(tabId, isNowActive)
172+
})
173+
}
174+
}

src/background/index.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
import { listTabs } from "@api/chrome/tab"
99
import { isNoneWindowId, onNormalWindowFocusChanged } from "@api/chrome/window"
1010
import optionHolder from "@service/components/option-holder"
11+
import itemService from "@service/item-service"
12+
import whitelistHolder from "@service/whitelist/holder"
1113
import { isBrowserUrl } from "@util/pattern"
1214
import { openLog } from "../common/logger"
1315
import ActiveTabListener from "./active-tab-listener"
16+
import AudioTabListener from "./audio-tab-listener"
1417
import BackupScheduler from "./backup-scheduler"
1518
import badgeTextManager from "./badge-manager"
1619
import initBrowserAction from "./browser-action-manager"
@@ -59,11 +62,33 @@ initWhitelistMenuManager()
5962
// Badge manager
6063
badgeTextManager.init(messageDispatcher)
6164

65+
// Track the currently active tab
66+
let activeTabId: number | null = null
67+
const audioTabListener = new AudioTabListener()
68+
6269
// Listen to tab active changed
6370
new ActiveTabListener()
64-
.register(({ url, tabId }) => badgeTextManager.updateFocus({ url, tabId }))
71+
.register(({ url, tabId }) => {
72+
activeTabId = tabId
73+
audioTabListener.onActiveTabChanged(tabId)
74+
badgeTextManager.updateFocus({ url, tabId })
75+
})
6576
.listen()
6677

78+
// Listen to audio playback in background tabs
79+
audioTabListener
80+
.register(async ({ host, url, duration, tabId }) => {
81+
if (whitelistHolder.contains(host, url)) return
82+
83+
const focusTimeMs = duration * 1000
84+
await itemService.addFocusTime({ host, url }, focusTimeMs)
85+
86+
badgeTextManager.updateFocus({ url, tabId })
87+
})
88+
89+
// Make this async since listen() is now async
90+
audioTabListener.listen(() => activeTabId)
91+
6792
handleInstall()
6893

6994
// Start message dispatcher
@@ -76,6 +101,8 @@ onNormalWindowFocusChanged(async windowId => {
76101
tabs.forEach(tab => {
77102
const { url, id: tabId } = tab
78103
if (!url || isBrowserUrl(url) || !tabId) return
104+
activeTabId = tabId
105+
audioTabListener.onActiveTabChanged(tabId)
79106
badgeTextManager.updateFocus({ url, tabId })
80107
})
81108
})

0 commit comments

Comments
 (0)