YouTube Music Timer
Completed
Tool · DevTools

YouTube Music Timer

2025

Manifest V3 Chrome extension that measures listening time on music.youtube.com. A content script polls the page for playback state and heartbeats it out; accumulation happens in the service worker rather than the content script, which is what makes the total survive tab closes and SPA navigation. The popup shows the running total.

Built with
JavaScriptJavaScript
Chrome ExtensionsChrome Extensions
Project Details

STATUS
Completed
YEAR

2025

TYPE

Tool · DevTools

TAGS
Extension
Productivity

YouTube Music will not tell you how many hours a week you actually spend in it, and I was curious. Built in an evening as a Manifest V3 extension that measures listening time from the page itself. There is no public API to ask, and no exported history to import, so the only place the answer exists is the running tab. Six files, no build step, no dependencies - manifest.json, a content script, a service worker, a popup, its stylesheet and one icon.

Detecting playback without an API

There is no clean "is playing" signal to read off the page, so the content script infers it: once a second it looks for a button[aria-label="Pause"] in the DOM, on the logic that the control only reads Pause while audio is running. Every tick sends a heartbeat to the service worker, and a change in state sends a separate status message.

That selector is the whole load-bearing assumption, and it is worth being clear about what it buys and what it costs. It buys a signal that survives YouTube renaming its CSS classes, which happens constantly, because it keys off an accessibility label rather than a style hook. It costs correctness in any browser not running in English, since aria-label is localised - the button reads "Pause" only in an English UI, and everywhere else the extension records a permanent zero without erroring. The honest alternative would have been to read the <video> element's paused property, or the Media Session API, either of which would have been language-independent.

Where the counting happens

Accumulation happens in the worker, not the content script, which is the part that makes it survive normal use. Every ten seconds the worker takes the delta since its own last tick and, if playback is on, adds it both to a running total and to a per-day bucket keyed by ISO date in chrome.storage.local. A tab that gets closed mid-song costs at most ten seconds.

The per-day bucket is a read-modify-write against storage on every tick rather than an in-memory map that gets flushed, so the daily history is durable even if the worker dies between writes. The date key is new Date().toISOString().split("T")[0], which means days roll over at UTC midnight rather than local midnight - fine for a personal counter, wrong for anyone east or west of the meridian who cares about which day a late-night session lands on.

What's in it

  • A one-second polling loop in the content script, started on window load and guarded so it is only ever created once.
  • A MutationObserver on document.body watching childList and subtree, which starts the polling interval if it is not already running. YouTube Music is a single-page app, so navigating between the library and a playlist produces no page load to hang a new timer on; the observer is the safety net for the case where the load event was missed or the DOM was swapped out from under the script. This is the piece that took the longest to get right.
  • Heartbeat plus status messages, one of each per tick, so the worker sees both the current state and the fact that a page is alive.
  • Total and per-day storage written together, with the total restored from chrome.storage.local on chrome.runtime.onStartup.
  • A popup showing today and all-time, formatted as hours and minutes by a small format() helper over the raw second count, on a dark card layout in a fixed 250px body.
  • Content script scoped to music.youtube.com and nothing else, with storage, tabs and scripting as the only permissions.
  • Player controls in the popup (play/pause, previous, next, current track) written against the player bar's DOM selectors.

Where it is fragile

The parts above work. These do not, and finding out why is most of what the project taught.

The popup's player controls are wired to the wrong side of the message boundary. The popup sends chrome.tabs.sendMessage to the active tab, but the chrome.runtime.onMessage listener that would call simulateClick on .play-pause-button #button is defined in the popup's own inline script rather than in content.js. Nothing on the page is listening, so the buttons send into the void and the track title never resolves. Moving that listener into the content script is the entire fix.

The worker's message handler sets isPlaying only on a status message; the heartbeat branch just logs. Since status is emitted on transitions and not on every tick, a service worker that Chrome evicts and respawns mid-song comes back believing nothing is playing and stays that way until the next real play or pause. The same eviction resets totalSeconds to zero in memory, because it is only rehydrated on onStartup, which fires at browser launch and not on respawn - so the next write can clobber the stored total. A ten-second setInterval inside an MV3 worker is the wrong primitive for this; chrome.alarms is the one that survives eviction, and reading the total out of storage before each write instead of trusting the in-memory copy would remove the reset entirely.

Two smaller things. scripting is declared in the manifest and never called. And the three icon sizes are one 27 KB PNG copied to three filenames.

Nothing here is clever. It is a counter, a heartbeat and an ISO date string, and it answers the question I built it for.

Project Details

STATUS
Completed
YEAR

2025

TYPE

Tool · DevTools

TAGS
Extension
Productivity