More Projects
59 in totalYouTube 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.
Project Details
2025
Tool · DevTools
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
windowload and guarded so it is only ever created once. - A
MutationObserverondocument.bodywatchingchildListandsubtree, 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.localonchrome.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.comand nothing else, withstorage,tabsandscriptingas 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
2025
Tool · DevTools