import throttle from 'lodash/throttle' import { fromBfCache } from '../../lib/from-bfcache' import { inViewport } from '../../lib/in-viewport' import { Emitter } from '../event-emitter' interface Options { idleInterval?: number checkIdleIntervalMs?: number start?: boolean } export class FocusTimer extends Emitter { private focusStart?: number private lastFocusStart?: number private isFocused = false private idleInterval: number private idleIntervalCheck: number | undefined private checkIdleIntervalMs = 1000 private idleMediaTimer: number | undefined private registered = false private interacted = false public constructor(opts: Options = {}) { super() this.isFocused = false this.idleInterval = opts.idleInterval || 15000 this.checkIdleIntervalMs = opts.checkIdleIntervalMs || 1000 if (opts.start !== false) { this.startAutocapture() } } public startAutocapture = () => { if (!document.hidden && !this.isFocused) { this.startFocus() } this.registerListeners() } public stopAutocapture = () => { this.unregisterListeners() this.endFocus() } public restart = () => { if (this.isFocused) { this.endFocus() } if (!document.hidden) { this.startFocus() } } private registerListeners = () => { if (this.registered) return this.registered = true document.addEventListener('visibilitychange', this.onVisibilityChangeWrapper) window.addEventListener('blur', this.onBlur) window.addEventListener('focus', this.onFocus) // Use capture phase because scrolling some sections of the page won't trigger it otherwise window.addEventListener('scroll', this.pulse, { capture: true, passive: true }) // Use passive listeners for optimal performance document.addEventListener('mousedown', this.pulse, { passive: true }) document.addEventListener('mousemove', this.pulse, { passive: true }) document.addEventListener('touchstart', this.pulse, { passive: true }) document.addEventListener('touchmove', this.pulse, { passive: true }) document.addEventListener('keydown', this.pulse, { passive: true }) document.addEventListener('keyup', this.pulse, { passive: true }) document.addEventListener('click', this.pulse, { passive: true }) document.addEventListener('contextmenu', this.pulse, { passive: true }) document.addEventListener('play', this.pulse, { capture: true, passive: true }) // listen for back/forward navigation to reset the timer window.addEventListener('pageshow', this.onBfCacheRestore) // start a recursive setTimeout to check for any playing media this.checkMedia() this.checkIdleTime() } private unregisterListeners = () => { if (!this.registered) return window.clearTimeout(this.idleIntervalCheck) window.clearTimeout(this.idleMediaTimer) window.removeEventListener('blur', this.onBlur) window.removeEventListener('focus', this.onFocus) window.removeEventListener('scroll', this.pulse, { capture: true }) document.removeEventListener('visibilitychange', this.onVisibilityChangeWrapper) document.removeEventListener('mousedown', this.pulse) document.removeEventListener('mousemove', this.pulse) document.removeEventListener('touchstart', this.pulse) document.removeEventListener('touchmove', this.pulse) document.removeEventListener('keydown', this.pulse) document.removeEventListener('keyup', this.pulse) document.removeEventListener('click', this.pulse) document.removeEventListener('contextmenu', this.pulse) document.removeEventListener('play', this.pulse, { capture: true }) window.removeEventListener('pageshow', this.onBfCacheRestore) this.registered = false } private onBfCacheRestore = (e: PageTransitionEvent) => { if (fromBfCache(e) && document.visibilityState === 'visible') { this.startFocus() this.checkMedia() this.checkIdleTime() } } private startFocus = () => { const now = performance.now() this.isFocused = true this.focusStart = now this.lastFocusStart = now this.emit('focus_time.start', this.focusStart) } private endFocus = () => { // ending focus should cancel any delayed pulse invocations this.pulse.cancel() this.emit('focus_time.end', this.currentFocusTime) this.isFocused = false } private onVisibilityChangeWrapper = () => this.onVisibilityChange(document.visibilityState) public checkIdleTime = () => { window.clearTimeout(this.idleIntervalCheck) // always flush pending pulse invocations before checking idle time this.pulse.flush() if (this.idleTime >= this.idleInterval) { this.endFocus() } this.idleIntervalCheck = window.setTimeout(() => this.checkIdleTime(), this.checkIdleIntervalMs) } public get idleTime() { if (this.isFocused && typeof this.lastFocusStart === 'number') { return performance.now() - this.lastFocusStart } else { return 0 } } public get currentFocusTime() { if (!this.interacted) { return 0 } if (this.isFocused && typeof this.focusStart === 'number') { return performance.now() - this.focusStart } else { return 0 } } /** * This function is only exposed for testing, do not use it. */ public onBlur = () => { if (this.isFocused) { this.endFocus() } } public onFocus = () => { if (!this.isFocused) { this.startFocus() } } /** * This function is only exposed for testing, do not use it. */ public onVisibilityChange = (visibilityState: string) => { if (visibilityState === 'visible') { this.onFocus() } else if (visibilityState === 'hidden') { this.onBlur() } } /** * This function is only exposed for testing, do not use it. */ public pulse = throttle( () => { this.interacted = true if (!this.isFocused) { this.startFocus() } else { this.lastFocusStart = performance.now() } }, 500, { leading: true, trailing: true } ) public checkMedia = () => { window.clearTimeout(this.idleMediaTimer) const players = document.querySelectorAll('video') const playing = Array.from(players).filter((player) => { // ignore paused videos if (player.paused) { return false } // ignore looping videos (often used for gifs-as-a-video for resolution + size + perf reasons) if (player.loop) { return false } // ignore videos that are muted and don't have controls visible if (player.muted && !player.controls) { return false } // ignore videos that don't have enough info yet to actually be playing anything if (player.readyState < 2) { return false } // ignore videos that are not in the viewport return inViewport(player) }) if (playing.length > 0 && document.visibilityState === 'visible') { this.pulse() } this.idleMediaTimer = window.setTimeout(() => this.checkMedia(), this.checkIdleIntervalMs) } public clear() { this.restart() } }