How can I save the progress of a video in a user's session on my website, like YouTube or Netflix do?
I have a video player created with html and javascript, I share my travels, I would like that when the user enters my website, when he watches a video his progress is saved in his session.
Can you help me? I don't know how to implement it
const playPauseBtn = document.querySelector(".play-pause-btn")
const fullScreenBtn = document.querySelector(".full-screen-btn")
const muteBtn = document.querySelector(".mute-btn")
const captionsBtn = document.querySelector(".captions-btn")
const speedBtn = document.querySelector(".speed-btn")
const currentTimeElem = document.querySelector(".current-time")
const totalTimeElem = document.querySelector(".total-time")
const volumeSlider = document.querySelector(".volume-slider")
const videoContainer = document.querySelector(".video-container")
const timelineContainer = document.querySelector(".timeline-container")
const video = document.querySelector("video")
const controlsContainer = document.querySelector('.video-controls-container');
let controlsTimeout;
controlsContainer.style.opacity = '0';
const displayControls = () => {
controlsContainer.style.opacity = '1';
document.body.style.cursor = 'initial';
if (controlsTimeout) {
clearTimeout(controlsTimeout);
}
controlsTimeout = setTimeout(() => {
controlsContainer.style.opacity = '0';
document.body.style.cursor = 'none';
}, 7000);
};
document.addEventListener("keydown", e => {
const tagName = document.activeElement.tagName.toLowerCase()
if (tagName === "input") return
switch (e.key.toLowerCase()) {
case " ":
if (tagName === "button") return
case "k":
togglePlay()
break
case "f":
toggleFullScreenMode()
break
case "t":
toggleTheaterMode()
break
case "i":
toggleMiniPlayerMode()
break
case "m":
toggleMute()
break
case "arrowleft":
case "j":
skip(-5)
break
case "arrowright":
case "l":
skip(5)
break
case "c":
toggleCaptions()
break
}
})
window.addEventListener('unload', () => {
const video = document.getElementById('PoS')
const timestamp = video.currentTime
window.localStorage.setItem('PoS', timestamp.toString())
})
// to restore the video timestamp
window.addEventListener('load', () => {
const timestamp = document.localStorage.getItem('PoS')
if (timestamp) {
const video = document.getElementById('PoS')
video.currentTime = parseInt(timestamp, 10)
}
})
document.addEventListener('mousemove', () => {
displayControls();
});
// Timeline
timelineContainer.addEventListener("mousemove", handleTimelineUpdate)
timelineContainer.addEventListener("mousedown", toggleScrubbing)
document.addEventListener("mouseup", e => {
if (isScrubbing) toggleScrubbing(e)
})
document.addEventListener("mousemove", e => {
if (isScrubbing) handleTimelineUpdate(e)
})
let isScrubbing = false
let wasPaused
function toggleScrubbing(e) {
const rect = timelineContainer.getBoundingClientRect()
const percent = Math.min(Math.max(0, e.x - rect.x), rect.width) / rect.width
isScrubbing = (e.buttons & 1) === 1
videoContainer.classList.toggle("scrubbing", isScrubbing)
if (isScrubbing) {
wasPaused = video.paused
video.pause()
} else {
video.currentTime = percent * video.duration
if (!wasPaused) video.play()
}
handleTimelineUpdate(e)
}
function handleTimelineUpdate(e) {
const rect = timelineContainer.getBoundingClientRect()
const percent = Math.min(Math.max(0, e.x - rect.x), rect.width) / rect.width
const previewImgNumber = Math.max(
1,
Math.floor((percent * video.duration) / 10)
)
const previewImgSrc = `assets/previewImgs/preview${previewImgNumber}.jpg`
previewImg.src = previewImgSrc
timelineContainer.style.setProperty("--preview-position", percent)
if (isScrubbing) {
e.preventDefault()
thumbnailImg.src = previewImgSrc
timelineContainer.style.setProperty("--progress-position", percent)
}
}
// Playback Speed
speedBtn.addEventListener("click", changePlaybackSpeed)
function changePlaybackSpeed() {
let newPlaybackRate = video.playbackRate 0.25
if (newPlaybackRate > 2) newPlaybackRate = 0.25
video.playbackRate = newPlaybackRate
speedBtn.textContent = `${newPlaybackRate}x`
}
// Captions
const captions = video.textTracks[0]
captions.mode = "hidden"
captionsBtn.addEventListener("click", toggleCaptions)
function toggleCaptions() {
const isHidden = captions.mode === "hidden"
captions.mode = isHidden ? "showing" : "hidden"
videoContainer.classList.toggle("captions", isHidden)
}
// Duration
video.addEventListener("loadeddata", () => {
totalTimeElem.textContent = formatDuration(video.duration)
})
video.addEventListener("timeupdate", () => {
currentTimeElem.textContent = formatDuration(video.currentTime)
const percent = video.currentTime / video.duration
timelineContainer.style.setProperty("--progress-position", percent)
})
const leadingZeroFormatter = new Intl.NumberFormat(undefined, {
minimumIntegerDigits: 2,
})
function formatDuration(time) {
const seconds = Math.floor(time % 60)
const minutes = Math.floor(time / 60) % 60
const hours = Math.floor(time / 3600)
if (hours === 0) {
return `${minutes}:${leadingZeroFormatter.format(seconds)}`
} else {
return `${hours}:${leadingZeroFormatter.format(
minutes
)}:${leadingZeroFormatter.format(seconds)}`
}
}
function skip(duration) {
video.currentTime = duration
}
// Volume
muteBtn.addEventListener("click", toggleMute)
volumeSlider.addEventListener("input", e => {
video.volume = e.target.value
video.muted = e.target.value === 0
})
function toggleMute() {
video.muted = !video.muted
}
video.addEventListener("volumechange", () => {
volumeSlider.value = video.volume
let volumeLevel
if (video.muted || video.volume === 0) {
volumeSlider.value = 0
volumeLevel = "muted"
} else if (video.volume >= 0.5) {
volumeLevel = "high"
} else {
volumeLevel = "low"
}
videoContainer.dataset.volumeLevel = volumeLevel
})
fullScreenBtn.addEventListener("click", toggleFullScreenMode)
function toggleFullScreenMode() {
if (document.fullscreenElement == null) {
videoContainer.requestFullscreen()
} else {
document.exitFullscreen()
}
}
document.addEventListener("fullscreenchange", () => {
videoContainer.classList.toggle("full-screen", document.fullscreenElement)
})
// Play/Pause
playPauseBtn.addEventListener("click", togglePlay)
video.addEventListener("click", togglePlay)
function togglePlay() {
video.paused ? video.play() : video.pause()
}
video.addEventListener("play", () => {
videoContainer.classList.remove("paused")
})
video.addEventListener("pause", () => {
videoContainer.classList.add("paused")
})
document.addEventListener('mousemove', () => {
displayControls();
});
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@100;200;300;400;500;600;700&display=swap');
*{
box-sizing: border-box;
margin: 0;
}
/* html{
display:flex;
} */
body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.video-container{
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: black;
}
video{
width: 100%;
height: 100%;
}
.video-container.theater,
.video-container.full-screen {
max-width: initial;
width: 100%;
}
.video-container.theater {
max-height: 90vh;
}
.video-container.full-screen {
max-height: 100vh;
}
.volver{
width:38px;
position: absolute;
bottom: 93vh;
left: 1%;
}
.p{
margin: 2px 0;
font-size: 1.4rem;
width: 100%;
text-align: center;
font-family: 'Poppins', sans-serif;
font-weight: 500;
}
.timeline, .timeline-container{
margin: 0 1%;
border-radius: 30px;
}
.full-screen-btn{
margin-right: 1%;
}
@media screen and (-webkit-min-device-pixel-ratio:0) {
input[type='range'] {
overflow: hidden;
width: 0px;
-webkit-appearance: none;
background-color: #565656;
cursor: pointer;
}
input[type='range']::-webkit-slider-runnable-track {
height: 6px;
-webkit-appearance: none;
color: #ff0000;
margin-top: -1px;
cursor: pointer;
}
input[type='range']::-webkit-slider-thumb {
width: 0px;
-webkit-appearance: none;
height: 10px;
cursor: ew-resize;
background: #434343;
box-shadow: -80px 0 0 80px #ff0000;
cursor: pointer;
}
}
/** FF*/
input[type="range"]::-moz-range-progress {
background-color: #fb0000;
}
input[type="range"]::-moz-range-track {
background-color: #5a5a5a;
}
/* IE*/
input[type="range"]::-ms-fill-lower {
background-color: #ff0000;
}
input[type="range"]::-ms-fill-upper {
background-color: #5d5d5d;
}
.pause-icon{
padding-bottom: 3px;
}
.controls{
margin-left: 1%;
margin-top: 1%;
margin-bottom: 1%;
}
.video-controls-container {
position: absolute;
bottom: 0;
left: 0;
right: 0;
color: white;
z-index: 100;
opacity: 0;
transition: opacity 150ms ease-in-out;
}
.video-controls-container::before {
content: "";
position: absolute;
bottom: 0;
background: linear-gradient(to top, rgba(0, 0, 0, .75), transparent);
width: 100%;
aspect-ratio: 6 / 1;
z-index: -1;
pointer-events: none;
}
.video-container:hover .video-controls-container,
.video-container:focus-within .video-controls-container,
.video-container.paused .video-controls-container {
opacity: 1;
}
.video-controls-container .controls {
display: flex;
gap: .5rem;
padding: .25rem;
align-items: center;
}
.video-controls-container .controls button {
background: none;
border: none;
color: inherit;
padding: 0;
height: 30px;
width: 30px;
font-size: 1.1rem;
cursor: pointer;
opacity: .85;
transition: opacity 150ms ease-in-out;
}
.video-controls-container .controls button:hover {
opacity: 1;
}
.video-container.paused .pause-icon {
display: none;
}
.video-container:not(.paused) .play-icon {
display: none;
}
.video-container.theater .tall {
display: none;
}
.video-container:not(.theater) .wide {
display: none;
}
.video-container.full-screen .open {
display: none;
}
.video-container:not(.full-screen) .close {
display: none;
}
.volume-high-icon,
.volume-low-icon,
.volume-muted-icon {
display: none;
}
.video-container[data-volume-level="high"] .volume-high-icon {
display: block;
}
.video-container[data-volume-level="low"] .volume-low-icon {
display: block;
}
.video-container[data-volume-level="muted"] .volume-muted-icon {
display: block;
}
.volume-container {
display: flex;
align-items: center;
}
.volume-slider {
width: 0;
transform-origin: left;
transform: scaleX(0);
transition: width 150ms ease-in-out, transform 150ms ease-in-out;
}
.volume-container:hover .volume-slider,
.volume-slider:focus-within {
width: 100px;
transform: scaleX(1);
}
.duration-container {
display: flex;
align-items: center;
gap: .25rem;
flex-grow: 1;
font-family: 'Poppins', sans-serif;
font-weight: 300;
}
.video-container.captions .captions-btn {
border-bottom: 3px solid red;
}
.video-controls-container .controls button.wide-btn {
width: 50px;
}
.timeline-container {
height: 7px;
margin-inline: .5rem;
cursor: pointer;
display: flex;
align-items: center;
}
.timeline {
background-color: rgba(100, 100, 100, .5);
height: 3px;
width: 100%;
position: relative
}
.timeline::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: calc(100% - var(--preview-position) * 100%);
background-color: rgb(150, 150, 150);
display: none;
}
.timeline::after {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: calc(100% - var(--progress-position) * 100%);
background-color: red;
border-radius: 50px;
}
.timeline .thumb-indicator {
--scale: 0;
position: absolute;
transform: translateX(-50%) scale(var(--scale));
height: 200%;
top: -50%;
left: calc(var(--progress-position) * 100%);
background-color: red;
border-radius: 50px;
transition: transform 150ms ease-in-out;
aspect-ratio: 1 / 1;
}
.timeline .preview-img {
position: absolute;
height: 80px;
aspect-ratio: 16 / 9;
top: -1rem;
transform: translate(-50%, -100%);
left: calc(var(--preview-position) * 100%);
border-radius: .25rem;
border: 2px solid white;
display: none;
}
.thumbnail-img {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
display: none;
}
.video-container.scrubbing .thumbnail-img {
display: block;
}
.video-container.scrubbing .preview-img,
.timeline-container:hover .preview-img {
display: block;
}
.video-container.scrubbing .timeline::before,
.timeline-container:hover .timeline::before {
display: block;
}
.video-container.scrubbing .thumb-indicator,
.timeline-container:hover .thumb-indicator {
--scale: 1;
}
.video-container.scrubbing .timeline,
.timeline-container:hover .timeline {
height: 100%;
}
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="../../../../../img/logos/HOLLYWOOD (2).png" type="image/x-icon">
<title>Video Player</title>
<link rel="stylesheet" href="../styles.css">
<script src="../script.js" defer></script>
</head>
<body>
<div id="wrapper" id="c-controls" data-volume-level="high">
<div >
<!-- <img src="https://www.themoviedb.org/t/p/original/d6xsLOwe76FLpo47zovFkBKpvQg.png"> -->
<div id="controles">
<a href="#"><svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" stroke-width="1.5" stroke="#ff2825" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<line x1="5" y1="12" x2="19" y2="12" />
<line x1="5" y1="12" x2="11" y2="18" />
<line x1="5" y1="12" x2="11" y2="6" />
</svg></a>
<div >
<div >
<div ></div>
</div>
</div>
<div >
<button >
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 24 24" stroke-width="1.5" stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M7 4v16l13 -8z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" stroke-width="1.5" stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
</button>
<div >
<button >
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" stroke-width="1.5" stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M15 8a5 5 0 0 1 0 8" />
<path d="M17.7 5a9 9 0 0 1 0 14" />
<path d="M6 15h-2a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h2l3.5 -4.5a0.8 .8 0 0 1 1.5 .5v14a0.8 .8 0 0 1 -1.5 .5l-3.5 -4.5" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" stroke-width="1.5" stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M15 8a5 5 0 0 1 0 8" />
<path d="M17.7 5a9 9 0 0 1 0 14" />
<path d="M6 15h-2a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h2l3.5 -4.5a0.8 .8 0 0 1 1.5 .5v14a0.8 .8 0 0 1 -1.5 .5l-3.5 -4.5" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" stroke-width="1.5" stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M6 15h-2a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h2l3.5 -4.5a0.8 .8 0 0 1 1.5 .5v14a0.8 .8 0 0 1 -1.5 .5l-3.5 -4.5" />
<path d="M16 10l4 4m0 -4l-4 4" />
</svg>
</button>
<input type="range" min="0" max="1" step="any" value="1">
</div>
<div >
<div >0:00</div>
/
<div ></div>
</div>
<p >Orlando / 2022</p>
<button >
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" stroke-width="1.5" stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<circle cx="12" cy="12" r="9" />
<path d="M14.5 9a3.5 4 0 1 0 0 6" />
</svg>
<button >
1x
</button>
<button >
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" stroke-width="1.5" stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<line x1="3" y1="19" x2="3.01" y2="19" />
<path d="M7 19a4 4 0 0 0 -4 -4" />
<path d="M11 19a8 8 0 0 0 -8 -8" />
<path d="M15 19h3a3 3 0 0 0 3 -3v-8a3 3 0 0 0 -3 -3h-12a3 3 0 0 0 -2.8 2" />
</svg>
</button>
<!-- <a href="#" >
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" stroke-width="1.5" stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<polyline points="12 4 4 8 12 12 20 8 12 4" />
<polyline points="4 12 12 16 20 12" />
<polyline points="4 16 12 20 20 16" />
</svg>
</a> -->
<button >
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" stroke-width="1.5" stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<polyline points="16 4 20 4 20 8" />
<line x1="14" y1="10" x2="20" y2="4" />
<polyline points="8 20 4 20 4 16" />
<line x1="4" y1="20" x2="10" y2="14" />
<polyline points="16 20 20 20 20 16" />
<line x1="14" y1="14" x2="20" y2="20" />
<polyline points="8 4 4 4 4 8" />
<line x1="4" y1="4" x2="10" y2="10" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" stroke-width="1.5" stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
<path d="M4 16v2a2 2 0 0 0 2 2h2" />
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
<path d="M16 20h2a2 2 0 0 0 2 -2v-2" />
</svg>
</button>
</div>
</div>
</div>
<video preload id="PoS" src="orlando.mp4">
<track kind="captions" srclang="es" src="assets/subtitles.vtt">
</video>
</div>
</body>
</html>
CodePudding user response:
I think Netflix and youtube use websockets
to acheive what you want, but a REST API should work fine.
// you should get your session ID through the session cookie
GET /video/:id => should return the saved timestamp
POST /video/:id => should save the session timestamp
You can find the official documentation of an HMTL5 video player here.
On your server, set the timestamp in the document head through a script tag in your PHP controller / template :
<script>window.SESSION_DATA = { ['my_video_id']: { timestamp: MY_PHP_VARIABLE }}</script>
On the document : You'll need to use eventListeners, something like :
var previousTimestamp = 0
function saveVideoTimestamp() {
const video = document.getElementById('my_video_id')
if (previousTimestamp !== video.currentTime) {
previousTimestamp = video.currentTime
const xhttp = new XMLHttpRequest()
xhttp.open("POST", "/video/my_video_id", true)
xhttp.setRequestHeader('Content-type', 'application/json');
xhttp.send(JSON.stringify({ timestamp: previousTimestamp }));
}
}
// restore the video timestamp
window.addEventListener('load', () => {
const timestamp = window.SESSION_DATA['my_video_id']
// you could also get it through AJAX here
if (timestamp) {
const video = document.getElementById('my_video_id')
video.currentTime = parseInt(timestamp, 10)
previousTimestamp = video.currentTime
}
// save the timestamp through DB polling (every 10s)
setInterval(saveVideoTimestamp, 10000)
})
This is fairly basic code to get you started, but without more insight on your architecture we won't be able to help.