Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/api/chrome/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,10 @@ export function onTabUpdated(handler: TabHandler<ChromeTabChangeInfo>): void {
handler(tabId, changeInfo, tab)
})
}

export function onTabRemoved(handler: (tabId: number) => void): void {
chrome.tabs.onRemoved.addListener((tabId: number) => {
handleError("tabRemoved")
handler(tabId)
})
}
174 changes: 174 additions & 0 deletions src/background/audio-tab-listener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Copyright (c) 2021 Hengyang Zhang
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
import { onTabRemoved, onTabUpdated } from "@api/chrome/tab"
import { extractHostname, type HostInfo } from "@util/pattern"

type AudioState = {
host: string
url: string
tabId: number
lastCheckTime: number
isCurrentlyActive: boolean
}

type AudioEventParam = {
url: string
tabId: number
host: string
duration: number // seconds of audio playback
}

type AudioEventHandler = (params: AudioEventParam) => void

const STORAGE_KEY = 'audioPlayingTabsState'
const PERSIST_INTERVAL_MS = 5000 // Save state every 5 seconds

export default class AudioTabListener {
private listeners: AudioEventHandler[] = []
private audioPlayingTabs: Map<number, AudioState> = new Map()
private persistenceInterval: number | null = null

register(handler: AudioEventHandler): AudioTabListener {
this.listeners.push(handler)
return this
}

private notifyListeners(param: AudioEventParam) {
this.listeners.forEach(func => func(param))
}

private handleAudioStart(tabId: number, url: string, isActiveTab: boolean) {
if (!url) return

const hostInfo: HostInfo = extractHostname(url)
const host: string = hostInfo.host

const existingState = this.audioPlayingTabs.get(tabId)

if (existingState && existingState.url === url) {
return
}

this.audioPlayingTabs.set(tabId, {
host,
url,
tabId,
lastCheckTime: Date.now(),
isCurrentlyActive: isActiveTab
})
}

private handleAudioStop(tabId: number) {
const state = this.audioPlayingTabs.get(tabId)
if (!state) return

// Record any remaining time before stopping
this.recordSegment(state)

this.audioPlayingTabs.delete(tabId)
}

private recordSegment(state: AudioState) {
const now = Date.now()
const duration = Math.floor((now - state.lastCheckTime) / 1000)

// Only record if tab was INACTIVE during this segment
if (duration > 0 && !state.isCurrentlyActive) {
this.notifyListeners({
url: state.url,
tabId: state.tabId,
host: state.host,
duration
})
}

// Reset check time for next segment
state.lastCheckTime = now
}

private handleTabActivationChange(tabId: number, isNowActive: boolean) {
const state = this.audioPlayingTabs.get(tabId)
if (!state) return

// If state changed, record the previous segment
if (state.isCurrentlyActive !== isNowActive) {
this.recordSegment(state)
state.isCurrentlyActive = isNowActive
}
}

private async restoreState() {
try {
const result = await chrome.storage.local.get(STORAGE_KEY)
const stateArray = result[STORAGE_KEY]
if (stateArray && Array.isArray(stateArray)) {
this.audioPlayingTabs = new Map(stateArray)
}
} catch (e) {
// Ignore errors - state will start fresh
console.warn('Failed to restore audio tracking state:', e)
}
}

private async saveState() {
// Don't save if no tabs are playing audio
if (this.audioPlayingTabs.size === 0) return

try {
const stateArray = Array.from(this.audioPlayingTabs.entries())
await chrome.storage.local.set({ [STORAGE_KEY]: stateArray })
} catch (e) {
// Ignore errors - will retry on next interval
console.warn('Failed to save audio tracking state:', e)
}
}

private startPersistence() {
// Persist state periodically to survive service worker restarts (Chrome/Edge)
this.persistenceInterval = setInterval(() => {
this.saveState()
}, PERSIST_INTERVAL_MS) as unknown as number
}

private stopPersistence() {
if (this.persistenceInterval !== null) {
clearInterval(this.persistenceInterval)
this.persistenceInterval = null
}
}

async listen(getActiveTabId: () => number | null) {
// Restore state on startup (handles Chrome service worker restarts)
await this.restoreState()

// Start periodic persistence
this.startPersistence()

onTabUpdated((tabId, changeInfo, tab) => {
if (changeInfo.audible !== undefined) {
if (changeInfo.audible && tab?.url) {
const activeTabId = getActiveTabId()
const isActive = tabId === activeTabId
this.handleAudioStart(tabId, tab.url, isActive)
} else {
this.handleAudioStop(tabId)
}
}
})

onTabRemoved(tabId => {
this.handleAudioStop(tabId)
})
}

onActiveTabChanged(newActiveTabId: number | null) {
this.audioPlayingTabs.forEach((state, tabId) => {
const isNowActive = tabId === newActiveTabId
this.handleTabActivationChange(tabId, isNowActive)
})
}
}
29 changes: 28 additions & 1 deletion src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
import { listTabs } from "@api/chrome/tab"
import { isNoneWindowId, onNormalWindowFocusChanged } from "@api/chrome/window"
import optionHolder from "@service/components/option-holder"
import itemService from "@service/item-service"
import whitelistHolder from "@service/whitelist/holder"
import { isBrowserUrl } from "@util/pattern"
import { openLog } from "../common/logger"
import ActiveTabListener from "./active-tab-listener"
import AudioTabListener from "./audio-tab-listener"
import BackupScheduler from "./backup-scheduler"
import badgeTextManager from "./badge-manager"
import initBrowserAction from "./browser-action-manager"
Expand Down Expand Up @@ -59,11 +62,33 @@ initWhitelistMenuManager()
// Badge manager
badgeTextManager.init(messageDispatcher)

// Track the currently active tab
let activeTabId: number | null = null
const audioTabListener = new AudioTabListener()

// Listen to tab active changed
new ActiveTabListener()
.register(({ url, tabId }) => badgeTextManager.updateFocus({ url, tabId }))
.register(({ url, tabId }) => {
activeTabId = tabId
audioTabListener.onActiveTabChanged(tabId)
badgeTextManager.updateFocus({ url, tabId })
})
.listen()

// Listen to audio playback in background tabs
audioTabListener
.register(async ({ host, url, duration, tabId }) => {
if (whitelistHolder.contains(host, url)) return

const focusTimeMs = duration * 1000
await itemService.addFocusTime({ host, url }, focusTimeMs)

badgeTextManager.updateFocus({ url, tabId })
})

// Make this async since listen() is now async
audioTabListener.listen(() => activeTabId)

handleInstall()

// Start message dispatcher
Expand All @@ -76,6 +101,8 @@ onNormalWindowFocusChanged(async windowId => {
tabs.forEach(tab => {
const { url, id: tabId } = tab
if (!url || isBrowserUrl(url) || !tabId) return
activeTabId = tabId
audioTabListener.onActiveTabChanged(tabId)
badgeTextManager.updateFocus({ url, tabId })
})
})
Expand Down