<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hui</title>
    <description>The latest articles on DEV Community by Hui (@claytonchow).</description>
    <link>https://dev.to/claytonchow</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4020357%2Fbbf89c0d-1547-416e-9f48-23759cd8c20b.png</url>
      <title>DEV Community: Hui</title>
      <link>https://dev.to/claytonchow</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/claytonchow"/>
    <language>en</language>
    <item>
      <title>The unglamorous reality of maintaining a streaming extension as a solo dev</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Thu, 09 Jul 2026 06:47:31 +0000</pubDate>
      <link>https://dev.to/claytonchow/the-unglamorous-reality-of-maintaining-a-streaming-extension-as-a-solo-dev-4k2f</link>
      <guid>https://dev.to/claytonchow/the-unglamorous-reality-of-maintaining-a-streaming-extension-as-a-solo-dev-4k2f</guid>
      <description>&lt;p&gt;Tuesday, 8:15 AM. I was waiting for the kettle to boil when the email notification popped up on my phone. &lt;/p&gt;

&lt;p&gt;&lt;em&gt;Chrome Web Store Developer Support: Action Required for StreamEnhancer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My stomach did that familiar little drop. It wasn’t a malware flag, and it wasn’t a copyright strike. It was a policy rejection under the "Interfering with third-party website functionality" clause. &lt;/p&gt;

&lt;p&gt;Building StreamEnhancer—the extension that lets you fine-tune playback speed on YouTube, Netflix, Disney+, Prime, and Max—was the fun part. Figuring out how to inject UI overlays into wildly different video players and keeping the audio pitch intact at 2.5x speed? That was a great puzzle. &lt;/p&gt;

&lt;p&gt;But nobody warns you about the administrative and emotional overhead of keeping a media-manipulation extension alive in the wild. &lt;/p&gt;

&lt;p&gt;Here is what actually takes up my time these days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CWS Review Tug-of-War
&lt;/h2&gt;

&lt;p&gt;When you build an extension that touches major streaming platforms, you are automatically flagged for deeper manual review by the Chrome Web Store team. And honestly? I get it. The web is full of malicious extensions trying to intercept video streams or inject ads into Netflix. &lt;/p&gt;

&lt;p&gt;But StreamEnhancer doesn't touch DRM. It doesn't decrypt Widevine. It literally just finds the HTML5 &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element and tweaks the &lt;code&gt;playbackRate&lt;/code&gt; property. &lt;/p&gt;

&lt;p&gt;Explaining this to a reviewer who is processing hundreds of extensions a day is a diplomatic exercise, not a technical one. &lt;/p&gt;

&lt;p&gt;When I got that Tuesday morning rejection, the reviewer cited a policy about modifying the core experience of a third-party service. I couldn't just reply with "but it's just the standard HTML5 API." I had to build a case.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What actually works with CWS reviewers:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A short, unlisted YouTube video (or Loom) showing the extension in action.&lt;/li&gt;
&lt;li&gt;A screen recording of the Chrome DevTools Network tab, proving no encrypted media streams are being intercepted or modified.&lt;/li&gt;
&lt;li&gt;A written breakdown mapping your extension's features directly to standard, documented browser APIs.&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;

&lt;p&gt;I spent two hours recording a video, pausing to explicitly point out that the video source URLs were untouched, and writing a polite, overly detailed explanation of how &lt;code&gt;HTMLMediaElement.playbackRate&lt;/code&gt; is a native browser feature, not a hack. &lt;/p&gt;

&lt;p&gt;It took four days to get approved. Four days where my existing users were stuck on the old version, and new users couldn't install it. You just have to sit with that anxiety and keep checking your email.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Is Netflix Broken or Is It You?" Support Queue
&lt;/h2&gt;

&lt;p&gt;The other massive time sink is user support. And with streaming extensions, support is deeply psychological.&lt;/p&gt;

&lt;p&gt;Streaming platforms update their DOM structures and player wrappers constantly. Disney+ is particularly notorious for tweaking their web player on random Thursday nights. When they change the class names on their player controls, my injected speed buttons might misalign or disappear entirely.&lt;/p&gt;

&lt;p&gt;When that happens, my inbox fills up. &lt;/p&gt;

&lt;p&gt;But here’s the thing: users don't email me saying, "Hey, the DOM structure on the Disney+ web player changed, causing your overlay to fail." &lt;/p&gt;

&lt;p&gt;They email me saying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Did Netflix ban my account?"&lt;/li&gt;
&lt;li&gt;"Why is my TV freezing?"&lt;/li&gt;
&lt;li&gt;"Your extension broke my streaming, I want a refund."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you're a solo dev, you aren't just fixing code. You are de-escalating panic. People pay $15 to $25 a month for these streaming services. They feel entitled to a flawless experience. If my $3 extension accidentally covers up the "Skip Intro" button because of a z-index miscalculation, they are going to be annoyed.&lt;/p&gt;

&lt;p&gt;I had to learn to separate my ego from their frustration. &lt;/p&gt;

&lt;p&gt;Early on, I would get defensive. I’d write back long explanations about how Netflix changed their code, not me. It didn't help. It just made them angrier.&lt;/p&gt;

&lt;p&gt;Now, my support process is entirely about speed and validation. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Acknowledge immediately.&lt;/strong&gt; "You're right, the player updated and the controls are hidden. I'm working on the fix right now."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provide a temporary workaround.&lt;/strong&gt; "In the meantime, you can use the keyboard shortcuts (Shift + &amp;gt;) to change speed without the UI."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship the fix and follow up.&lt;/strong&gt; &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I also set up a very specific FAQ page. The very first question is: &lt;em&gt;"Is my streaming service broken, or is it StreamEnhancer?"&lt;/em&gt; I included a quick guide on how to disable the extension to test if the native player works. It cut my "my TV is broken" support tickets by about 60%.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reality of the solo dev maintenance loop
&lt;/h2&gt;

&lt;p&gt;Look, building the thing is maybe 20% of the job. &lt;/p&gt;

&lt;p&gt;The other 80% is monitoring the Chrome Web Store policy updates, keeping an eye on streaming platform changelogs (or just waiting for users to tell me they changed something), and managing the expectations of people who just want to watch &lt;em&gt;The Bear&lt;/em&gt; at 1.25x speed without the audio sounding like chipmunks.&lt;/p&gt;

&lt;p&gt;It’s unglamorous. It’s a lot of email writing and policy reading. But when I get a random five-star review from a med student who says StreamEnhancer saved them hundreds of hours of lecture watching? &lt;/p&gt;

&lt;p&gt;Yeah. I'll happily deal with the CWS review queue for that.&lt;/p&gt;

</description>
      <category>netflix</category>
      <category>youtube</category>
      <category>disney</category>
      <category>streaming</category>
    </item>
    <item>
      <title>Stop relying on `timeupdate`: Building battery-efficient video analytics in the browser</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Thu, 09 Jul 2026 06:45:20 +0000</pubDate>
      <link>https://dev.to/claytonchow/stop-relying-on-timeupdate-building-battery-efficient-video-analytics-in-the-browser-j07</link>
      <guid>https://dev.to/claytonchow/stop-relying-on-timeupdate-building-battery-efficient-video-analytics-in-the-browser-j07</guid>
      <description>&lt;p&gt;Sunday mornings are for pour-over coffee and catching up on tech courses. A few months ago, I was working through an advanced Rust module on one of the big e-learning platforms. I paused, scrubbed back to re-watch a tricky section on lifetimes, and then skipped ahead. &lt;/p&gt;

&lt;p&gt;When I checked my dashboard later, the platform told me I was 85% done with the course. &lt;/p&gt;

&lt;p&gt;I wasn't. I was maybe 40% done. The platform's progress tracking was essentially just looking at the highest timestamp I'd reached, completely ignoring the scrubbing and re-watching. It was garbage data. &lt;/p&gt;

&lt;p&gt;That specific frustration is what pushed me to build CourseSpeed. I wanted actual, granular learning analytics and better playback speed controls across Udemy, Coursera, and the rest. But building a browser extension that tracks video engagement accurately without turning the user's laptop into a space heater taught me a hard lesson about how we traditionally handle video events in the DOM.&lt;/p&gt;

&lt;p&gt;If you're building anything that interacts with HTML5 video—whether it's a custom player, an analytics script, or a browser extension—you need to stop using the &lt;code&gt;timeupdate&lt;/code&gt; event. &lt;/p&gt;

&lt;p&gt;Here is what you should be doing instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;timeupdate&lt;/code&gt; trap
&lt;/h2&gt;

&lt;p&gt;If you open the dev tools on almost any video player and attach a listener to &lt;code&gt;timeupdate&lt;/code&gt;, you'll see it firing constantly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The old, bad way&lt;/span&gt;
&lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timeupdate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;logAnalytics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem? The HTML5 spec doesn't dictate exactly how often &lt;code&gt;timeupdate&lt;/code&gt; should fire. It just says it should fire when the playback position changes. In practice, browsers fire it anywhere from 4 to 60 times a second. &lt;/p&gt;

&lt;p&gt;If you're just updating a UI progress bar, that's fine. But if you're doing what I do in CourseSpeed—calculating watch-time density, tracking playback speed changes, and syncing local analytics to a backend—that unpredictable firing rate is a nightmare. You end up doing heavy computations on the main thread at random intervals, causing micro-stutters in the video playback and draining battery life on mobile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enter &lt;code&gt;requestVideoFrameCallback&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Chrome 83 introduced &lt;code&gt;requestVideoFrameCallback&lt;/code&gt; (often abbreviated as rVFC), and it's now supported in all major Chromium-based browsers and Safari. Firefox is still dragging its feet on it, but for extension development and modern web apps, it's a game changer.&lt;/p&gt;

&lt;p&gt;Instead of relying on a time-based event, rVFC hooks directly into the browser's rendering pipeline. It fires exactly once per rendered video frame.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;trackFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// metadata.mediaTime gives you the exact presentation timestamp&lt;/span&gt;
    &lt;span class="c1"&gt;// metadata.presentedFrames tells you how many frames have been shown&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Do your analytics math here&lt;/span&gt;
    &lt;span class="nf"&gt;calculateEngagement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Queue the next frame&lt;/span&gt;
    &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trackFrame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Kick it off&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trackFrame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why is this better? &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's battery efficient.&lt;/strong&gt; It aligns with the display's refresh rate and the video's actual frame rate. If the video is 30fps, it fires 30 times a second. If the user pauses, it stops firing completely. No wasted cycles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You get metadata.&lt;/strong&gt; The callback passes a &lt;code&gt;metadata&lt;/code&gt; object that includes &lt;code&gt;mediaTime&lt;/code&gt;, &lt;code&gt;presentedFrames&lt;/code&gt;, and &lt;code&gt;expectedDisplayTime&lt;/code&gt;. This lets you detect if the video is actually dropping frames or buffering, which is incredible for diagnosing playback quality issues.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When I migrated CourseSpeed's core tracking loop from &lt;code&gt;timeupdate&lt;/code&gt; to &lt;code&gt;requestVideoFrameCallback&lt;/code&gt;, CPU usage during a 2-hour course dropped by about 14% on my M1 MacBook. That's not a marginal gain. That's the difference between a user keeping your extension enabled and uninstalling it because it's "slowing down their browser."&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the video tag in the wild
&lt;/h2&gt;

&lt;p&gt;Of course, getting the analytics loop right is only half the battle. The other half is actually finding the &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element. &lt;/p&gt;

&lt;p&gt;E-learning platforms don't just hand you a clean &lt;code&gt;&amp;lt;video src="..."&amp;gt;&lt;/code&gt; tag. They bury it. Udemy uses nested iframes. Coursera wraps things in complex React trees. Skillshare sometimes throws shadow DOMs into the mix. &lt;/p&gt;

&lt;p&gt;If you're writing a content script, &lt;code&gt;document.querySelector('video')&lt;/code&gt; is going to fail you 90% of the time. &lt;/p&gt;

&lt;p&gt;I ended up writing a recursive walker that pierces same-origin iframes and open shadow roots. It's a bit brute-force, but it works reliably across the platforms I support.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findVideoElements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Check current root&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;videos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="c1"&gt;// Pierce open shadow DOMs&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shadowHosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;shadowHosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRoot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;findVideoElements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Pierce same-origin iframes&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iframes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;iframe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;iframes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iframe&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// This will throw if cross-origin, which is fine, we just catch it&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iframeDoc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;iframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contentDocument&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;iframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contentWindow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nf"&gt;findVideoElements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iframeDoc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Cross-origin iframe. &lt;/span&gt;
            &lt;span class="c1"&gt;// If the platform uses postMessage for player control, &lt;/span&gt;
            &lt;span class="c1"&gt;// you have to hook into that instead.&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You don't want to run this on every DOM mutation. Run it once on page load, and then set up a highly specific &lt;code&gt;MutationObserver&lt;/code&gt; that only watches for the insertion of new &lt;code&gt;iframe&lt;/code&gt; or &lt;code&gt;video&lt;/code&gt; nodes. &lt;/p&gt;

&lt;h2&gt;
  
  
  A quick note on Firefox
&lt;/h2&gt;

&lt;p&gt;Since Firefox doesn't support &lt;code&gt;requestVideoFrameCallback&lt;/code&gt; yet, you need a fallback. Don't just revert to &lt;code&gt;timeupdate&lt;/code&gt; blindly. &lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;requestAnimationFrame&lt;/code&gt; combined with checking &lt;code&gt;video.currentTime&lt;/code&gt;. It's not as perfectly synced to the video decoder as rVFC, but it ties your logic to the browser's paint cycle rather than the video element's internal clock, which is still vastly superior to the random firing of &lt;code&gt;timeupdate&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fallbackLoop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;calculateEngagement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fallbackLoop&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Building tools that sit on top of other people's web apps is always a bit of a cat-and-mouse game. The platforms change their DOM structures, they update their player APIs, and things break. But by relying on low-level browser APIs like &lt;code&gt;requestVideoFrameCallback&lt;/code&gt; instead of high-level DOM events, you anchor your code to the rendering engine itself. &lt;/p&gt;

&lt;p&gt;It makes your analytics smoother, your extension lighter, and your Sunday morning coffee breaks a lot less frustrating.&lt;/p&gt;

</description>
      <category>onlinelearning</category>
      <category>udemy</category>
      <category>coursera</category>
      <category>studyfaster</category>
    </item>
    <item>
      <title>4.2 million hours of video later: What StreamEnhancer telemetry taught me about human impatience</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Thu, 09 Jul 2026 06:32:15 +0000</pubDate>
      <link>https://dev.to/claytonchow/42-million-hours-of-video-later-what-streamenhancer-telemetry-taught-me-about-human-impatience-38in</link>
      <guid>https://dev.to/claytonchow/42-million-hours-of-video-later-what-streamenhancer-telemetry-taught-me-about-human-impatience-38in</guid>
      <description>&lt;p&gt;1.14x. &lt;/p&gt;

&lt;p&gt;That was the exact median playback speed across all StreamEnhancer users last month. &lt;/p&gt;

&lt;p&gt;I was sitting in a coffee shop on a Tuesday morning, running the monthly aggregation script against our anonymized telemetry database. I honestly expected the number to be higher. If you spend any time on tech Twitter or Reddit, you’d think everyone is watching tutorials at 2.5x speed. The internet loves to brag about media speedrunning. "I watch lectures at 3x, I'm basically a cyborg."&lt;/p&gt;

&lt;p&gt;But the aggregate data told a completely different story. The reality of how people actually consume media is a lot more nuanced, and honestly, a lot more interesting than the hustle-culture speedrun narrative suggests. &lt;/p&gt;

&lt;p&gt;When you build a tool like StreamEnhancer, you start with assumptions. I assumed people just wanted to go fast. The data proved me wrong. People don't just want to go fast; they want to go &lt;em&gt;exactly as fast as their brain can process without feeling stressed&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Here is what 4.2 million hours of streaming telemetry actually looks like.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Platform Personality Split
&lt;/h3&gt;

&lt;p&gt;The 1.14x median is basically a useless number on its own because it mashes together completely different behaviors. When I broke the data down by platform, the variance was wild. It turns out, the platform dictates the psychology.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Netflix and Max users are "Set and Forgetters."&lt;/strong&gt;&lt;br&gt;
The median speed on Netflix is 1.25x. But more importantly, the average number of speed changes per session is just &lt;strong&gt;0.8&lt;/strong&gt;. People find a comfortable cruising speed for a narrative show, set it, and leave it alone. They are optimizing for a steady state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YouTube users are "Active Pilots."&lt;/strong&gt;&lt;br&gt;
The median speed on YouTube is actually lower (1.10x), but the average number of speed changes per video is &lt;strong&gt;4.6&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;YouTube is a chaotic mix of content. A single 20-minute video might have a slow-talking intro, a dense technical explanation, a 60-second sponsor read, and a rapid-fire conclusion. Users are constantly riding the speed slider like a clutch pedal. They drop to 1.0x when the creator is showing complex code on screen, bump it to 2.0x for the sponsor pitch, and settle at 1.25x for the talking-head segments. &lt;/p&gt;

&lt;p&gt;I thought I was building a tool for speedrunners. I actually built a pacing tool for people trying to manage their cognitive load.&lt;/p&gt;
&lt;h3&gt;
  
  
  The 0.05x Obsession
&lt;/h3&gt;

&lt;p&gt;This was the biggest surprise in the dataset, and it completely changed my product roadmap.&lt;/p&gt;

&lt;p&gt;When I first shipped StreamEnhancer, the speed controls jumped in 0.25x increments. 1.0 → 1.25 → 1.5. Standard stuff. That's how the native HTML5 &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element usually handles it if you just hook into &lt;code&gt;playbackRate&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;In version 2.4, I added fine-grained control, allowing 0.05x steps. I figured maybe 10% of power users would care. &lt;/p&gt;

&lt;p&gt;Instead, &lt;strong&gt;71% of all speed changes became micro-adjustments.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;People weren't jumping from 1.0 to 1.5. They were nudging. &lt;br&gt;
&lt;code&gt;1.10 → 1.15 → 1.20 → 1.15.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;They were hunting for the exact threshold where the video felt fast, but the audio didn't start sounding artificial. Even with the &lt;code&gt;preservesPitch&lt;/code&gt; property updates in recent Chrome versions (which does a great job preventing the "chipmunk" effect), pushing past 1.3x on certain audio tracks introduces weird phasing artifacts. &lt;/p&gt;

&lt;p&gt;Users are highly sensitive to this. They will spend 15 seconds nudging the speed up and down by 0.05x just to find the "sweet spot" for a specific video's audio mix. &lt;/p&gt;

&lt;p&gt;Here’s what a typical telemetry payload looks like for a YouTube session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"speed_change"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"youtube"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_id_hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a8f9c2e..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"previous_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"new_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"delta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_total_changes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"time_in_video_sec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;342&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because of this data, I completely ripped out the "preset speed" buttons in the UI overlay. Presets are useless to an Active Pilot. I replaced them with a drag-slider that has subtle haptic-style snapping at 1.0x and 1.25x, but allows free-floating micro-adjustments everywhere else. Usage satisfaction scores jumped 22% in the next update.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Rewind Paradox
&lt;/h3&gt;

&lt;p&gt;Here's a weird correlation I found while digging through the event logs. &lt;/p&gt;

&lt;p&gt;I track (anonymously) when the playback speed is increased, and when the user triggers a "rewind 10 seconds" event via our custom hotkeys. &lt;/p&gt;

&lt;p&gt;If a user is watching at 1.5x or higher, the likelihood of them hitting "rewind 10s" within the next 30 seconds is &lt;strong&gt;340% higher&lt;/strong&gt; than if they are watching at 1.0x.&lt;/p&gt;

&lt;p&gt;Obvious, right? You go too fast, you miss a detail, you rewind. &lt;/p&gt;

&lt;p&gt;But here's the kicker that made me stop and think: &lt;strong&gt;they rarely drop the speed back down after rewinding.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;They miss a concept at 1.5x. They rewind 10 seconds. They watch it &lt;em&gt;again&lt;/em&gt; at 1.5x. If they miss it again, they rewind &lt;em&gt;again&lt;/em&gt; at 1.5x. &lt;/p&gt;

&lt;p&gt;Human attention is a funny thing. We would rather rewind three times at 1.5x speed (taking 20 seconds total) than just watch it once at 1.0x speed (taking 10 seconds). The math doesn't make sense for actual time saved, but it makes perfect sense psychologically. &lt;/p&gt;

&lt;p&gt;The &lt;em&gt;perception&lt;/em&gt; of moving fast matters more than the actual time saved. Dropping the speed to 1.0x feels like a penalty. It feels like you're "losing" the speedrun. Rewinding at 1.5x feels like you're just buffering your brain while maintaining your momentum.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the numbers actually mean
&lt;/h3&gt;

&lt;p&gt;Looking at analytics for a browser extension is different from looking at SaaS dashboard metrics. You aren't tracking churn or MRR. You're tracking human impatience, attention spans, and the subtle ways people try to hack their own biology to consume more information.&lt;/p&gt;

&lt;p&gt;The data from StreamEnhancer taught me that when you build consumer tools, you shouldn't build for the extreme edge cases (the 3.0x speedrunners). You should build for the messy, nuanced middle. The people who just want to watch a Netflix show at 1.25x so they can go to sleep 12 minutes earlier, or the developer who wants to nudge a tutorial to 1.15x so it matches their reading speed.&lt;/p&gt;

&lt;p&gt;Next time you watch a video and feel the urge to speed it up, pay attention to the exact number you choose. You're probably not just trying to save time. You're just trying to sync the internet to your own internal clock.&lt;/p&gt;

</description>
      <category>netflix</category>
      <category>youtube</category>
      <category>disney</category>
      <category>streaming</category>
    </item>
    <item>
      <title>Bypassing the 2x speed limit on stubborn video players (and why `playbackRate` lies to you)</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Thu, 09 Jul 2026 06:29:40 +0000</pubDate>
      <link>https://dev.to/claytonchow/bypassing-the-2x-speed-limit-on-stubborn-video-players-and-why-playbackrate-lies-to-you-2b4o</link>
      <guid>https://dev.to/claytonchow/bypassing-the-2x-speed-limit-on-stubborn-video-players-and-why-playbackrate-lies-to-you-2b4o</guid>
      <description>&lt;p&gt;I was riding the train back from a conference last month, trying to finish a Coursera module on my laptop. The instructor was speaking at roughly the speed of a melting glacier. I hit the 2x button in the custom UI. It wasn't enough. &lt;/p&gt;

&lt;p&gt;I opened dev tools, typed &lt;code&gt;document.querySelector('video').playbackRate = 3.5&lt;/code&gt;, and watched the playback instantly snap back to 2.0. &lt;/p&gt;

&lt;p&gt;If you've ever written a browser extension, a userscript, or just tried to hack your way through a mandatory compliance training video, you've probably hit this exact wall. The HTML5 video API makes speed control look trivial. But modern learning platforms don't just use raw &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; tags. They wrap them in heavy state managers. &lt;/p&gt;

&lt;p&gt;Here is what's actually happening under the hood, and the DOM trick you need to bypass it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Synthetic Event Trap
&lt;/h2&gt;

&lt;p&gt;Platforms like Udemy, LinkedIn Learning, and Coursera usually build their players in React or Vue. When you manually set &lt;code&gt;video.playbackRate = 3.5&lt;/code&gt; in the console, the browser does its job and fires a &lt;code&gt;ratechange&lt;/code&gt; event. &lt;/p&gt;

&lt;p&gt;The framework's synthetic event system catches that event, checks its internal state, realizes &lt;code&gt;3.5&lt;/code&gt; is outside the bounds of their custom UI slider (which usually maxes out at 2.0), and forcefully clamps it back down by re-assigning the property. &lt;/p&gt;

&lt;p&gt;You aren't fighting the browser. You're fighting the framework's reconciliation loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Prototype Descriptor Hijacking
&lt;/h2&gt;

&lt;p&gt;You can't just stop the &lt;code&gt;ratechange&lt;/code&gt; event propagation reliably without breaking the player's internal analytics or UI updates. Instead, you need to intercept the property setter itself. &lt;/p&gt;

&lt;p&gt;By overriding the property descriptor on the &lt;code&gt;HTMLMediaElement&lt;/code&gt; prototype, you can silently ignore the framework's attempts to clamp the speed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetSpeed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;3.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;proto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;HTMLMediaElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Grab the browser's native descriptor&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;originalDescriptor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOwnPropertyDescriptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;proto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playbackRate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defineProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;proto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playbackRate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;originalDescriptor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// If the platform tries to clamp it to their UI max (usually &amp;lt;= 2.5), &lt;/span&gt;
        &lt;span class="c1"&gt;// and we've flagged this video for override, just ignore the set.&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;2.5&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__speedOverrideActive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; 
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;originalDescriptor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;set&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;configurable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Apply the override to the specific video instance&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__speedOverrideActive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Now set the speed. The framework's subsequent attempts to reset it to 2.0 will fail silently.&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playbackRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;targetSpeed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick heads up if you're building an extension: a lot of these platforms render their video player inside an iframe. &lt;code&gt;document.querySelector('video')&lt;/code&gt; will return null on the top-level window. You'll need to target the iframe's &lt;code&gt;contentDocument&lt;/code&gt; first, assuming it's not blocked by cross-origin restrictions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't forget the Chipmunk effect
&lt;/h2&gt;

&lt;p&gt;There's a secondary issue when you push past 2.0x or 2.5x. &lt;/p&gt;

&lt;p&gt;To save CPU cycles, some platforms explicitly disable audio pitch correction when the playback rate changes. In Chrome 130+, the standard property is &lt;code&gt;preservesPitch&lt;/code&gt;, but older WebKit/Blink implementations still rely on vendor prefixes. If your instructor suddenly sounds like an angry chipmunk at 3x speed, force the pitch preservation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preservesPitch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webkitPreservesPitch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Safari/older Chrome&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mozPreservesPitch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// Firefox&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It’s a tiny detail, but it makes the difference between a usable 3x speed and an unlistenable one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracking the "Rewind Density"
&lt;/h2&gt;

&lt;p&gt;Getting the video to play at high speeds is only half the battle if you actually want to learn something. &lt;/p&gt;

&lt;p&gt;When I was wiring up the core logic for CourseSpeed, I realized that just forcing a 3x playback rate was useless if the user had to constantly pause or rewind. I needed to know if the speed was actually sustainable for the content being watched.&lt;/p&gt;

&lt;p&gt;I ended up implementing a metric I call "rewind density." &lt;/p&gt;

&lt;p&gt;Instead of just logging the current playback speed, the analytics hook tracks the delta between high-speed playback and rewind events (specifically, dropping the speed back to 1.0x or hitting the left-arrow key to jump back 10 seconds). &lt;/p&gt;

&lt;p&gt;If you're building your own learning tools or analytics dashboards, don't just track &lt;em&gt;average watch speed&lt;/em&gt;. Track how often the user is forced to abandon that speed. A video watched at 2.5x with zero rewinds is a great fit. A video watched at 2.5x with a rewind every 40 seconds means the content is too dense, and the user is just creating an illusion of productivity.&lt;/p&gt;

&lt;p&gt;The DOM hacks get the video to play fast. But paying attention to how the user interacts with the player is what actually makes the speed useful.&lt;/p&gt;

</description>
      <category>onlinelearning</category>
      <category>udemy</category>
      <category>coursera</category>
      <category>studyfaster</category>
    </item>
    <item>
      <title>The 1x1 Pixel That Broke My Streaming Extension</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Thu, 09 Jul 2026 04:17:02 +0000</pubDate>
      <link>https://dev.to/claytonchow/the-1x1-pixel-that-broke-my-streaming-extension-3bmk</link>
      <guid>https://dev.to/claytonchow/the-1x1-pixel-that-broke-my-streaming-extension-3bmk</guid>
      <description>&lt;p&gt;Saturday morning, 9 AM. I was at the kitchen island, making a pour-over coffee and testing StreamEnhancer on my MacBook while chopping onions for a frittata. &lt;/p&gt;

&lt;p&gt;I had the extension running, playing an episode of &lt;em&gt;The Mandalorian&lt;/em&gt; on Disney+ at 1.5x speed. Everything was smooth. The audio pitch correction was holding up, the custom UI overlay was tracking the progress bar perfectly. Feeling confident, I hit my hotkey to bump the speed to 2.0x. &lt;/p&gt;

&lt;p&gt;Fourteen minutes later, the video froze. The audio cut out with a harsh digital stutter, and a generic "Something went wrong" error replaced the player.&lt;/p&gt;

&lt;p&gt;I figured it was a memory leak. StreamEnhancer injects a fairly heavy UI overlay and listens to a lot of media events to sync the speed indicators across YouTube, Netflix, Prime, Max, and Disney+. I restarted Chrome 128, loaded the page, and tried again. &lt;/p&gt;

&lt;p&gt;Exact same thing. 14 minutes at 2.0x, then death. &lt;/p&gt;

&lt;p&gt;I dropped the speed back to 1.5x. The episode finished without a hitch. &lt;/p&gt;

&lt;h3&gt;
  
  
  The naive assumption
&lt;/h3&gt;

&lt;p&gt;My initial code for applying the playback speed was, frankly, lazy. When you're building a cross-platform tool, you want to write the least amount of platform-specific code possible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The "please just work" approach&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;videos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playbackRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;targetSpeed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I assumed a streaming page would only have one active &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element. Or at least, only one that actually mattered. YouTube behaves exactly like this. Netflix is a bit more complex with its canvas rendering for certain UI elements, but the core media tag is straightforward. &lt;/p&gt;

&lt;p&gt;But Disney+ and Max? They are a different beast.&lt;/p&gt;

&lt;p&gt;I opened DevTools, cleared the console, and just searched the DOM for &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;There were three of them.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The main player. &lt;code&gt;videoWidth&lt;/code&gt; was 1920.&lt;/li&gt;
&lt;li&gt;A hidden element used for ad-tracking and analytics beacons.&lt;/li&gt;
&lt;li&gt;A 1x1 pixel video, positioned absolutely off-screen, with no audio.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The accidental discovery
&lt;/h3&gt;

&lt;p&gt;I paused the main movie. The 1x1 pixel video kept playing. I muted the tab. It kept playing. &lt;/p&gt;

&lt;p&gt;I dug into the network tab and watched the requests. That tiny, invisible video tag was streaming a continuous, low-bitrate media chunk. It turns out, this is a DRM heartbeat. &lt;/p&gt;

&lt;p&gt;When you stream protected content, the browser uses Encrypted Media Extensions (EME) to talk to a Content Decryption Module (CDM) like Widevine or PlayReady. The platform needs to verify the session is still valid and the environment hasn't been tampered with. Instead of relying purely on standard XHR/Fetch network requests—which can be easily intercepted, mocked, or blocked by ad-blockers—some platforms tie the license renewal to the playback lifecycle of a secondary, heavily monitored media element.&lt;/p&gt;

&lt;p&gt;The heartbeat video plays silently in the background. When it hits a certain timestamp, it triggers a license renewal. &lt;/p&gt;

&lt;p&gt;Here is where my extension broke everything.&lt;/p&gt;

&lt;p&gt;When I forced &lt;code&gt;playbackRate = 2.0&lt;/code&gt; on &lt;em&gt;every&lt;/em&gt; video tag in the DOM, I sped up the DRM heartbeat. &lt;/p&gt;

&lt;p&gt;The heartbeat video reached its renewal checkpoint twice as fast. The CDM fired a license renewal request to the server. But the server-side logic expects these renewals at specific real-world intervals. When it started receiving them at 2x speed, the platform's sanity check flagged the client as anomalous. It assumed the environment was tampered with, or the license simply burned through its validity window too fast.&lt;/p&gt;

&lt;p&gt;Result: Session revoked. Black screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Isolating the primary media
&lt;/h3&gt;

&lt;p&gt;I didn't want to mess with the DRM heartbeat. I just wanted to speed up the movie. I needed a reliable way to identify the &lt;em&gt;actual&lt;/em&gt; primary video tag across five different streaming platforms, ignoring the ghosts in the DOM.&lt;/p&gt;

&lt;p&gt;Checking &lt;code&gt;videoWidth &amp;gt; 0&lt;/code&gt; wasn't enough. Ad trackers can have dimensions. Checking if the element is visible in the viewport wasn't enough either, because picture-in-picture modes and minimized players change the bounding client rect.&lt;/p&gt;

&lt;p&gt;I ended up writing a scoring function for StreamEnhancer that runs every time the URL changes or the player initializes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findPrimaryMediaElement&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;maxScore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// 1. Dimension check (The most reliable filter)&lt;/span&gt;
        &lt;span class="c1"&gt;// Heartbeats and trackers are usually tiny&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoWidth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoHeight&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; 
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoWidth&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Heavily penalize the 1x1 pixels&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// 2. Rendered area check&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;area&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; 

        &lt;span class="c1"&gt;// 3. DOM hierarchy check&lt;/span&gt;
        &lt;span class="c1"&gt;// Primary players are usually nested deep in specific player containers&lt;/span&gt;
        &lt;span class="c1"&gt;// while trackers are often injected at the root body level&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;maxScore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;maxScore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nx"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It’s not perfect. Every time Netflix or Prime updates their player architecture, I have to tweak the weights. Prime Video, for instance, sometimes wraps their main video in a shadow DOM during initial load before hydrating it into the main document, which required adding a &lt;code&gt;MutationObserver&lt;/code&gt; just to catch the element when it finally surfaces.&lt;/p&gt;

&lt;p&gt;But the scoring system solved the DRM crash. By only applying &lt;code&gt;playbackRate&lt;/code&gt; to the highest-scoring element, the 1x1 pixel heartbeat continues to play at 1.0x, the server stays happy, and the user gets to watch their show at 2.5x without getting kicked out.&lt;/p&gt;

&lt;h3&gt;
  
  
  The web is a battlefield
&lt;/h3&gt;

&lt;p&gt;Before building StreamEnhancer, I thought of the DOM as a document. A tree of elements that represent what the user sees. &lt;/p&gt;

&lt;p&gt;Working across Netflix, Max, Disney+, and the others taught me that on modern streaming sites, the DOM is a battlefield. There are decoys, trackers, heartbeats, and hidden state machines all fighting for resources and verifying each other. &lt;/p&gt;

&lt;p&gt;When you build tools that interact with these platforms, you aren't just manipulating a video tag. You're stepping into a highly paranoid ecosystem. Sometimes, the most important part of your code isn't the feature you're building, but the invisible things you've learned to leave alone.&lt;/p&gt;

</description>
      <category>netflix</category>
      <category>youtube</category>
      <category>disney</category>
      <category>streaming</category>
    </item>
    <item>
      <title>I thought building a video speed controller would take a weekend. The analytics nearly broke me.</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Thu, 09 Jul 2026 03:10:27 +0000</pubDate>
      <link>https://dev.to/claytonchow/i-thought-building-a-video-speed-controller-would-take-a-weekend-the-analytics-nearly-broke-me-pb1</link>
      <guid>https://dev.to/claytonchow/i-thought-building-a-video-speed-controller-would-take-a-weekend-the-analytics-nearly-broke-me-pb1</guid>
      <description>&lt;p&gt;It was 2 AM on a Tuesday, and I was staring at my CourseSpeed dashboard looking at a graph that claimed I had just finished a 14-hour AWS certification course in 47 minutes. &lt;/p&gt;

&lt;p&gt;I hadn't. I was just testing the 16x speed toggle. But my analytics engine thought I was a god.&lt;/p&gt;

&lt;p&gt;When I started building CourseSpeed—a browser extension to inject custom playback speeds and track learning analytics across Udemy, Coursera, LinkedIn Learning, and Skillshare—I thought the hard part would be the UI. It wasn't. Injecting a floating control panel and setting &lt;code&gt;document.querySelector('video').playbackRate = 2.5&lt;/code&gt; takes about ten lines of JavaScript. &lt;/p&gt;

&lt;p&gt;The actual nightmare was the learning analytics. Specifically, accurately tracking &lt;em&gt;effective&lt;/em&gt; watch time versus &lt;em&gt;wall-clock&lt;/em&gt; time across wildly different Single Page Applications (SPAs).&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive approach that burned me
&lt;/h2&gt;

&lt;p&gt;My first pass at the analytics tracker was straight out of MDN. I listened to the standard HTML5 video events.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The approach that worked perfectly in my head&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ratechange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;sendAnalytics&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;speed_change&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playbackRate&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timeupdate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;logWatchTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playbackRate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This worked flawlessly on Udemy. Then I opened LinkedIn Learning. The dashboard flatlined. Then I tried Coursera. The time spent was wildly inaccurate, drifting by minutes over an hour. &lt;/p&gt;

&lt;p&gt;I spent three days debugging this, tearing my hair out over console logs. &lt;/p&gt;

&lt;p&gt;Here is what I missed: modern learning platforms don't just drop a raw &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; tag on the page and leave it alone. They wrap it in custom players, throttle events to save CPU, and dynamically destroy and recreate the DOM node when you skip chapters or when the SPA router transitions. &lt;/p&gt;

&lt;p&gt;My event listeners were getting orphaned. Or worse, they were firing with stale data because the platform's custom wrapper was dispatching synthetic &lt;code&gt;timeupdate&lt;/code&gt; events that didn't reflect the underlying HTML5 video element's true state when the playback rate was manipulated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop trusting the DOM events
&lt;/h2&gt;

&lt;p&gt;I had to rip out the event listeners. If I wanted reliable analytics, I needed to observe the DOM for element swaps and poll the video state myself.&lt;/p&gt;

&lt;p&gt;I built a &lt;code&gt;VideoTracker&lt;/code&gt; class. Instead of waiting for the platform to tell me the video changed, I used a &lt;code&gt;MutationObserver&lt;/code&gt; to watch the specific player wrapper containers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MutationObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;mutations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mutation&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;mutations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;childList&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newVideo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newVideo&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;newVideo&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentVideo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachToVideo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newVideo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// We observe the highest stable container, not the whole document body&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;playerRoot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-player-root]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playerRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;childList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subtree&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This solved the orphaned listener problem. When Coursera swapped the video node during a module transition, my tracker caught it, detached the old state, and latched onto the new &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element within milliseconds.&lt;/p&gt;

&lt;p&gt;But then I hit the background tab wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The background tab throttling trap
&lt;/h2&gt;

&lt;p&gt;Here’s the real kicker. When you switch tabs to read documentation while a course plays in the background, Chrome (specifically around version 120 and later) aggressively throttles &lt;code&gt;setInterval&lt;/code&gt; and &lt;code&gt;setTimeout&lt;/code&gt; in background tabs to save battery and CPU.&lt;/p&gt;

&lt;p&gt;My analytics pings were getting delayed. The browser would batch them up and send massive, inaccurate chunks of "watch time" when the tab regained focus. If a user left a 2x speed video running in the background for an hour, my extension might only log 15 minutes of actual progress because the &lt;code&gt;setInterval&lt;/code&gt; ticking my internal clock was being put to sleep.&lt;/p&gt;

&lt;p&gt;I tried switching the internal clock to &lt;code&gt;requestAnimationFrame&lt;/code&gt;. That paused entirely in background tabs. &lt;/p&gt;

&lt;p&gt;The fix was moving the ticking clock to a Web Worker. Web Workers run on a separate thread and aren't subjected to the same aggressive throttling as the main thread's timers.&lt;/p&gt;

&lt;p&gt;The architecture ended up looking like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Web Worker ticks every 1000ms and posts a message to the main thread.&lt;/li&gt;
&lt;li&gt;The main thread receives the tick, reads &lt;code&gt;video.currentTime&lt;/code&gt; and &lt;code&gt;video.playbackRate&lt;/code&gt; directly from the DOM.&lt;/li&gt;
&lt;li&gt;The main thread calculates the delta and updates the local analytics state.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// worker.js&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;intervalId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;intervalId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tick&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stop&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intervalId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It feels like overkill for a browser extension, but it completely eliminated the background-tab drift. The analytics dashboard finally matched reality. A 14-hour course watched at 2x speed now correctly logged as 7 hours of effective learning time, regardless of whether the user was actively staring at the tab or reading MDN docs in another window.&lt;/p&gt;

&lt;p&gt;Building tools that live on top of other people's SPAs means you are constantly at the mercy of their DOM updates and browser performance optimizations. You can't just assume the standard APIs will behave the way the spec says they should in a vacuum. You have to build defensively, assume the DOM will mutate out from under you, and never trust a &lt;code&gt;setInterval&lt;/code&gt; in a background tab.&lt;/p&gt;

</description>
      <category>onlinelearning</category>
      <category>udemy</category>
      <category>coursera</category>
      <category>studyfaster</category>
    </item>
  </channel>
</rss>
