I Built a Custom Video Progress Tracker for My Course Platform (So Students Can't Skip Ahead)
There's a gap in most WordPress course plugins that nobody talks about.
Your student opens a lesson. They see a video. They immediately scrub to the end. They click "Mark Complete." The LMS records the lesson as finished.
They watched zero seconds of content.
For most course creators, this doesn't matter. But some of us run training programs that are recognized by regulatory bodies. In my case, the courses I build are approved by a state regulatory commission that requires verified time tracking. That means I can't just rely on an honor system. I need to prove that a student actually spent the required hours watching the content.
The Stack
My setup:
- WordPress running Sensei LMS for course delivery
- Bunny Player for video hosting (embedded via iframe)
- The old Sensei lesson progress plugin for tracking time spent on lessons
The Sensei lesson progress plugin already had a timer that tracked how long a student spent on a lesson page. That was a start, but it only measured page time, not video watch time. A student could open the lesson, go get coffee for 20 minutes, and the timer would happily record 20 minutes of "progress."
What I needed was a bridge between Bunny Player's video events and Sensei's progress tracking.
The Original Approach: Video.js
Before switching to Bunny Player, I was using Video.js — an open-source HTML5 video player. Video.js gives you direct DOM access to the video element. That makes progress tracking straightforward:
- Listen for
timeupdateevents to track current position - Listen for
seekedevents to detect when someone jumps ahead - If they seek past what they've watched, snap them back
- Only enable the "Mark Complete" button when the video reaches the end
Here's what that looked like:
// Video.js approach — direct DOM access
const player = videojs('my-video');
let maxWatched = 0;
player.on('timeupdate', function () {
const current = player.currentTime();
if (current > maxWatched) {
maxWatched = current;
}
});
player.on('seeked', function () {
if (player.currentTime() > maxWatched) {
player.currentTime(maxWatched);
}
});
player.on('ended', function () {
$('#complete-lesson-button').prop('disabled', false);
});
Simple. Synchronous. Works because Video.js is running in the same DOM.
This worked, but it required hosting video files directly or using a compatible CDN. When I moved to Bunny Player for better streaming performance, I lost direct DOM access to the video element. Bunny embeds videos in an iframe, and you can't reach inside an iframe from the parent page.
The Bunny Player Approach
Bunny Player supports an API bridge called player.js. It works through postMessage — the parent page sends commands to the iframe, and the iframe sends events back. It's a standard pattern for cross-origin iframe communication.
To make it work, you add &playerjs=true to the iframe's src attribute. This enables the bridge. Then you can listen for events like play, pause, ended, and timeupdate.
// Bunny Player approach — iframe bridge via postMessage
const iframe = document.querySelector('iframe[src*="iframe.mediadelivery.net"]');
const player = new Playerjs(iframe);
let maxWatched = 0;
let duration = 0;
let isReady = false;
player.on('ready', function () {
isReady = true;
player.getDuration(function (d) {
duration = d;
});
});
player.on('timeupdate', function (data) {
if (data.seconds > maxWatched) {
maxWatched = data.seconds;
}
});
player.on('seeked', function () {
player.getCurrentTime(function (current) {
if (current > maxWatched) {
player.setCurrentTime(maxWatched);
}
});
});
player.on('ended', function () {
// Enable Sensei's lesson completion
const btn = document.querySelector('#complete-lesson-button, .lesson_complete_button');
if (btn) {
btn.disabled = false;
btn.classList.remove('disabled');
}
});
The key difference: every API call is asynchronous. You can't read player.currentTime() synchronously like with Video.js. Every call sends a message and waits for a callback. That means you track state in variables and update them via callbacks rather than reading values on demand.
The Gotchas
This sounds simple. It wasn't.
Gotcha 1: The iframe needs &playerjs=true. Without it, postMessage events are ignored. Obvious in hindsight, easy to miss when you're just copying an iframe embed code from Bunny's dashboard.
<!-- Won't work — no bridge -->
<iframe src="https://iframe.mediadelivery.net/embed/..."></iframe>
<!-- Works — bridge enabled -->
<iframe src="https://iframe.mediadelivery.net/embed/...?&playerjs=true"></iframe>
Gotcha 2: loading="lazy" breaks the connection. If your iframe uses loading="lazy", it might not be in the DOM when your script runs. The ready event never fires because the bridge was never established. Remove lazy loading from the video iframe.
<!-- Don't do this -->
<iframe src="..." loading="lazy"></iframe>
<!-- Do this -->
<iframe src="...?&playerjs=true"></iframe>
Gotcha 3: Script timing. Your JavaScript needs to run after the iframe is in the DOM but doesn't need to wait for the video to load. The ready event handles the rest. I wrapped everything in a DOMContentLoaded listener and that was sufficient.
Gotcha 4: The Sensei timer interaction. The old Sensei lesson progress plugin has its own timer that runs on page load. I needed to plug into that existing system, not replace it. The custom video tracking code runs alongside it, and when the video ends, it triggers the completion logic that the plugin already has in place.
Plugging Into Sensei
The old Sensei lesson progress plugin tracks time on the page and enables a "Mark Complete" button when enough time has elapsed. My video tracking script works alongside it. Here's how they connect:
// After the video ends, trigger Sensei's existing completion flow
player.on('ended', function () {
const completeBtn = document.querySelector(
'#complete-lesson-button, .lesson_complete_button'
);
if (completeBtn) {
// Remove disabled state
completeBtn.disabled = false;
completeBtn.classList.remove('disabled');
// If Sensei's timer auto-submits, let it. If not, make the button clickable.
if (typeof senseiLessonProgress !== 'undefined') {
senseiLessonProgress.markComplete();
}
}
});
The video tracking ensures the student actually watched the content. Sensei's timer validates the total time. Together, they satisfy the regulatory requirement for verified seat time.
What I Learned
If you're building a course where completion tracking matters — especially for regulated industries — here are the key takeaways:
Choose your video player with tracking in mind. Self-hosted players like Video.js give you full DOM access. Hosted players like Bunny Player use iframe bridges that work differently. Both can track progress, but the implementation is completely different.
Test the bridge connection first. Before writing any tracking logic, verify that ready fires and that timeupdate events are coming through. Build an isolated test page with just the iframe and a console logger. If those don't work, nothing else will.
Don't trust page time as video time. A student can leave a lesson open for an hour without watching anything. If your LMS uses page time for progress, supplement it with actual video watch data.
Keep the seeking behavior reasonable. Block forward seeking past the watched point but allow backward seeking freely. Students legitimately need to rewatch sections. Being too aggressive with restrictions creates frustration that leads to support tickets.
Understand your regulatory requirements. Not every course needs this level of tracking. But if yours does, plan for it from the start. Retrofitting progress tracking onto a live course with existing students is a headache you don't want.
Is This Worth the Effort?
For most course creators, no. If you're selling a course on personal finance or social media marketing, nobody cares if someone skips ahead.
But if you're running a professional training program where completion matters — where a certificate or license depends on verified seat time — this is non-negotiable. The good news is that most WordPress LMS plugins are adding better video tracking. But if you're on an older setup, or using a video host that doesn't integrate directly with your LMS, a custom bridge like this fills the gap.
If you're building a course platform and running into gaps between your tools, I help people solve exactly these kinds of problems. Book a call.