What I Learned Building a Cross-Platform Video Speed Controller and Analytics Tracker
If you consume a lot of online courses, you know the struggle. Udemy caps out at 2x speed. Coursera’s player sometimes resets when you seek. LinkedIn Learning and Skillshare have their own quirky behaviors. Worse, none o
If you consume a lot of online courses, you know the struggle. Udemy caps out at 2x speed. Coursera’s player sometimes resets when you seek. LinkedIn Learning and Skillshare have their own quirky behaviors. Worse, none of them tell you how much actual focused time you spent learning versus just leaving the tab open.
I recently built CourseSpeed, a browser extension designed to solve this. It provides granular playback speed control and tracks genuine learning analytics across Udemy, Coursera, LinkedIn Learning, and Skillshare.
Building a tool that injects into four completely different Learning Management Systems (LMS) was a masterclass in DOM manipulation, state management, and browser extension architecture. Here are the core technical lessons I learned along the way.
Lesson 1: Taming the Fragmented Video DOM
The biggest initial hurdle was that every platform renders video differently. Udemy uses a custom wrapper, Coursera relies on heavy React components, and Skillshare uses standard HTML5 but with aggressive lazy loading.
You can’t just run document.querySelector('video') on page load. The video element often doesn't exist yet, or it gets destroyed and recreated when the user navigates to the next lesson.
The Solution: MutationObserver
Instead of polling the DOM (which kills performance), I used a MutationObserver to watch for the addition of <video> elements.
javascript
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeName === 'VIDEO') {
initializeSpeedController(node);
} else if (node.querySelectorAll) {
const videos = node.querySelectorAll('video');
videos.forEach(initializeSpeedController);
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
Key Takeaway: Always assume the DOM is hostile. Never trust that your target element exists on DOMContentLoaded. Use MutationObserver for dynamic, Single Page Application (SPA) environments.
Lesson 2: Forcing Playback Speed (and Keeping It)
Setting video.playbackRate = 2.5 is easy. Keeping it at 2.5 when the platform’s internal JavaScript tries to reset it to 1.0 is hard.
Platforms like Udemy have event listeners on seeked and play events that explicitly reset the playback rate to their default. If you just set the property, your speed will revert every time you skip forward 10 seconds.
The Solution: Object.defineProperty
To prevent the host page's JavaScript from overriding our speed, we need to hijack the playbackRate property on the HTMLMediaElement.prototype (or the specific element instance) and lock it in.
javascript
function lockPlaybackRate(videoElement, targetRate) {
let internalRate = targetRate;
Object.defineProperty(videoElement, 'playbackRate', {
get() {
return internalRate;
},
set(newValue) {
// Ignore attempts by the host page to reset the rate
if (newValue !== internalRate) {
console.log(Host page tried to change rate to ${newValue}. Ignored.);
}
},
configurable: true
});
videoElement.defaultPlaybackRate = targetRate;
}
[INSERT_CODE_SCREENSHOT]
Key Takeaway: When building browser extensions that modify media behavior, standard property assignment isn't enough. You must use property descriptors to intercept and block the host page's internal state management.
Lesson 3: Calculating "Effective" Learning Analytics
The native analytics on these platforms just track "video play time." But if you leave the video playing while you go to make a coffee, that’s not learning time. I wanted CourseSpeed to track effective learning time.
To calculate this, we need to account for:
- Tab visibility (is the user actually looking at the tab?).
- Buffering and pausing.
- Background throttling (browsers throttle
setIntervalin background tabs).
The Solution: Event-Driven Delta Tracking
Instead of using setInterval, which is unreliable in background tabs, I relied on the timeupdate event combined with the Page Visibility API.
javascript
let lastEffectiveTime = 0;
let totalEffectiveSeconds = 0;
videoElement.addEventListener('timeupdate', () => {
if (document.visibilityState === 'visible' && !videoElement.paused) {
const currentTime = videoElement.currentTime;
const delta = currentTime - lastEffectiveTime;
// Only count reasonable deltas (ignore massive jumps from seeking)
if (delta > 0 && delta < 2.0) {
totalEffectiveSeconds += delta;
}
lastEffectiveTime = currentTime;
}
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
lastEffectiveTime = videoElement.currentTime;
}
});
By calculating the delta between timeupdate events and ensuring the tab is visible, we get a highly accurate metric of actual focus time. We then sync this data to chrome.storage.local for the popup dashboard.
Key Takeaway: Never use setInterval for time-tracking in browser extensions. Browsers will throttle it to save battery. Rely on native media events and visibility states for accurate telemetry.
Lesson 4: Surviving Manifest V3 and State Management
Building this as a Chrome Extension meant dealing with Manifest V3. The shift from background pages to Service Workers means you can no longer rely on persistent background state.
When a user changes the speed in the extension popup, the popup closes, and the Service Worker might go to sleep. The content script needs to maintain the state independently, while periodically syncing with the background worker for cross-tab analytics.
The Solution: Decoupled State with Message Passing
I structured the extension with a strict separation of concerns:
- Content Script: Owns the DOM, the
MutationObserver, and the video element manipulation. It holds the local state for the current video. - Service Worker (Background): Owns the global analytics database (via
chrome.storage.local). It does not touch the DOM. - Popup/Options UI: Purely reads from and writes to the Service Worker.
Communication is handled entirely via chrome.runtime.sendMessage and chrome.runtime.onMessage.
javascript
// Content script sending analytics update
chrome.runtime.sendMessage({
type: 'UPDATE_ANALYTICS',
payload: {
platform: 'udemy',
courseId: extractCourseId(),
effectiveTime: totalEffectiveSeconds
}
});
Key Takeaway: In Manifest V3, treat the Service Worker as ephemeral. Keep your critical state in the content scripts or persistent storage, and use the background worker strictly as a message router and database manager.
Final Thoughts
Building CourseSpeed was a fantastic exercise in dealing with the messy reality of the modern web. When you build tools that integrate with third-party platforms, you have to expect the unexpected: shadow DOMs, aggressive React re-renders, and browser throttling.
If you're planning to build a browser extension or a tool that manipulates third-party DOMs, lean heavily on MutationObserver, property descriptors, and event-driven telemetry.
Have you built tools that interact with complex third-party SPAs? What hacks or patterns did you use to keep your code from breaking when they updated their UI? Let me know in the comments!
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.