Shows the advancement / remaining time
// ==UserScript==
// @name Lynda Countdown
// @description Shows the advancement / remaining time
// @match https://www.lynda.com/*.html*
// @run-at document-end
// @version 1.1.1
// @grant none
// @namespace https://greasyfork.org/users/213706
// ==/UserScript==
NodeList.prototype.forEach = Array.prototype.forEach;
if(typeof unsafeWindow == "undefined") {
unsafeWindow = window;
}
var LyndaCountdown = {
videoList: {},
totaltime: 0,
watchtime: 0,
/**
* Called when the page loads
* - calculate the total time
* - the watch time
* - add the event listeners
* - display the countdown
*/
init: function() {
var toc = document.getElementById('toc');
if(!toc) {
return;
}
this.totaltime = 0;
this.watchtime = 0;
// Observer current video
var observer = new MutationObserver(this.mutationCallback.bind(this));
// Loop videos in Table Of Contents
toc.querySelectorAll('.toc-video-item').forEach(function(node){
let id = node.getAttribute('data-video-id'),
duration = node.querySelector('.video-duration').innerHTML.trim(),
icon = node.querySelector('.watch-trigger');
if(!duration) {
return
}
observer.observe(node, { attributes: true });
// Get duration of video in seconds
let [matching, h, m, s] = duration.match(/^(\d+h)? *(\d+m)? *(\d+s)?$/),
time = 0;
if(h) time += parseInt(h.slice(0,-1)) * 60 * 60;
if(m) time += parseInt(m.slice(0,-1)) * 60;
if(s) time += parseInt(s.slice(0,-1));
// Check if the video has been watched or not
let watched = icon.classList.contains('eye');
// Add to the list
this.videoList[id] = {time, watched};
this.totaltime += time;
this.watchtime += watched ? time : 0;
}.bind(this));
// Display the countdown
this.render();
},
/**
* Called whenever the attribute of a .toc-video-item changes
* @param Array mutationsList
*/
mutationCallback: function(mutationsList) {
for(let mutation of mutationsList) {
if (mutation.type !== 'attributes' || mutation.attributeName !== 'class') {
return;
}
if(mutation.target.classList.contains('current')) {
this.currentVideo(mutation.target);
}
}
},
/**
* Called when the current active video changes
* @param DOMElement node
*/
currentVideo: function(node) {
var id = node.getAttribute('data-video-id');
// The user already watched this video
if(this.videoList[id].watched) {
return;
}
// Update the watch time
this.videoList[id].watched = 1;
this.watchtime += this.videoList[id].time;
this.updateCountdown();
},
/**
* Calculate the circumference of a circle knowing its radius
* @param float r
* @return float circ
*/
circ: function(r) {
return 2 * Math.PI * r;
},
/**
* Called by init
* - create the skeleton of the countdown
* - update the progress bar
* - display the countdown
*/
render: function() {
// https://codepen.io/xgad/post/svg-radial-progress-meters
var r = 22,
circ = Math.round(this.circ(r), 3);
// Create the HTML skeleton
var div = unsafeWindow.document.createElement('div');
div.setAttribute('id', 'countdown');
div.innerHTML = `
<div class="txt"></div>
<svg class="clip" width="50" height="50">
<circle class="bg" cx="50%" cy="50%" r="${r}"></circle>
<circle class="arc" cx="50%" cy="50%" r="${r}"></circle>
</svg>
<style>
#countdown {
position: absolute;
top: 12px;
right: 120px;
}
#countdown .bg {
fill: none;
stroke-width: 5px;
stroke: #ddd;
}
#countdown .arc {
fill: none;
stroke-width: 5;
stroke: #ffb900;
stroke-linecap: round;
stroke-dasharray: ${circ};
stroke-dashoffset: 0;
transform: rotate(-90deg);
transform-origin: 50% 50%;
animation: big 1.5s ease-in-out;
}
#countdown .txt {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 100%;
text-align: center;
white-space: nowrap;
}
</style>`;
// Update the progress bar
this.countdown = {
circ,
txtElem: div.querySelector('.txt'),
arcElem: div.querySelector('.arc'),
totalTxt: this.timeToHuman(this.totaltime)
};
this.updateCountdown();
// Add to the page
document.querySelector('.title-banner').appendChild(div);
},
/**
* Updates the countdown
* @param integer percent - Ex: 5
* @param string remainingTxt - Ex: 2h 30m
*/
updateCountdown: function() {
var remains = this.totaltime - this.watchtime,
percent = remains <= 0 ? 100 : Math.round(this.watchtime / this.totaltime * 100, 3),
deg = Math.ceil(this.countdown.circ * (100 - percent) / 100);
var txt_remains,
txt_title;
if(percent == 100) {
txt_remains = 'Done';
txt_title = 'You have completed 100% of this course';
} else {
txt_remains = `${this.timeToHuman(remains)}<br>left`;
txt_title = `You have completed ${parseInt(percent)}% of this course (${this.timeToHuman(this.watchtime)} / ${this.countdown.totalTxt})`;
}
this.countdown.arcElem.style['stroke-dashoffset'] = deg;
this.countdown.txtElem.setAttribute('title', txt_title);
this.countdown.txtElem.innerHTML = txt_remains;
},
/**
* Returns a string to display the time
* @param integer seconds
* @return string time
*/
timeToHuman(seconds) {
if(seconds <= 60) {
return seconds + 's';
}
if(seconds <= 3600) {
return parseInt(seconds / 60) + 'm '
+ (seconds % 60).toString().padStart(2, '0') + 's';
}
return parseInt(seconds / 3600) + 'h '
+ (parseInt(seconds / 60) % 60).toString().padStart(2, '0') + 'm';
}
};
unsafeWindow.onload = setTimeout(function(){
LyndaCountdown.init();
},500);