Custom Bunny HLS Lightbox (Advanced)
A fully custom HLS video lightbox powered by hls.js, with support for autoplay, mute, fullscreen, timeline scrubbing, and placeholder images. Opens over a dark overlay with smooth transitions.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.6.11"></script>Code
<div data-bunny-lightbox-status="not-active" class="bunny-lightbox">
<div data-bunny-lightbox-control="close" class="bunny-lightbox__dark"></div>
<div data-bunny-lightbox-calc-height="" class="bunny-lightbox__calc">
<div data-bunny-lightbox-init="" data-player-muted="false" data-player-fullscreen="false" data-player-activated="false" data-player-autoplay="true" data-player-hover="idle" data-player-src="" data-player-status="idle" data-player-update-size="true" class="bunny-lightbox-player">
<div data-player-before="" class="bunny-lightbox-player__before"></div>
<video preload="auto" width="1920" height="1080" playsinline="" class="bunny-lightbox-player__video"></video>
<img data-bunny-lightbox-placeholder="" src="" alt="" class="bunny-lightbox-player__placeholder">
<div class="bunny-lightbox-player__dark"></div>
<div data-player-control="playpause" class="bunny-lightbox-player__playpause">
<div class="bunny-lightbox-player__big-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 24 24" fill="none" class="bunny-lightbox-player__pause-svg"><path d="M16 5V19" stroke="currentColor" stroke-width="3" stroke-miterlimit="10"></path><path d="M8 5V19" stroke="currentColor" stroke-width="3" stroke-miterlimit="10"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 24 24" fill="none" class="bunny-lightbox-player__play-svg"><path d="M6 12V5.01109C6 4.05131 7.03685 3.4496 7.87017 3.92579L14 7.42855L20.1007 10.9147C20.9405 11.3945 20.9405 12.6054 20.1007 13.0853L14 16.5714L7.87017 20.0742C7.03685 20.5503 6 19.9486 6 18.9889V12Z" fill="currentColor"></path></svg>
</div>
</div>
<div class="bunny-lightbox-player__interface">
<div class="bunny-lightbox-player__interface-fade"></div>
<div class="bunny-lightbox-player__interface-bottom">
<div data-player-control="playpause" class="bunny-lightbox-player__toggle-playpause">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 24 24" fill="none" class="bunny-lightbox-player__pause-svg"><path d="M16 5V19" stroke="currentColor" stroke-width="3" stroke-miterlimit="10"></path><path d="M8 5V19" stroke="currentColor" stroke-width="3" stroke-miterlimit="10"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 24 24" fill="none" class="bunny-lightbox-player__play-svg"><path d="M6 12V5.01109C6 4.05131 7.03685 3.4496 7.87017 3.92579L14 7.42855L20.1007 10.9147C20.9405 11.3945 20.9405 12.6054 20.1007 13.0853L14 16.5714L7.87017 20.0742C7.03685 20.5503 6 19.9486 6 18.9889V12Z" fill="currentColor"></path></svg>
</div>
<div class="bunny-lightbox-player__time">
<p data-player-time-progress="" class="bunny-lightbox-player__text">00:00</p>
<p class="bunny-lightbox-player__text is--transparent">/</p>
<p data-player-time-duration="" class="bunny-lightbox-player__text is--transparent">00:00</p>
</div>
<div data-player-timeline="" class="bunny-lightbox-player__timeline">
<div class="bunny-lightbox-player__timeline-bar">
<div class="bunny-lightbox-player__timeline-bg"></div>
<div data-player-buffered="" class="bunny-lightbox-player__timeline-buffered"></div>
<div data-player-progress="" class="bunny-lightbox-player__timeline-progress"></div>
</div>
<div data-player-timeline-handle="" class="bunny-lightbox-player__timeline-handle"></div>
</div>
<div class="bunny-lightbox-player__interface-btns">
<div data-player-control="mute" class="bunny-lightbox-player__toggle-mute">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 24 24" fill="none" class="bunny-lightbox-player__volume-up-svg"><path d="M3 8.99998V15H7L12 20V3.99998L7 8.99998H3ZM16.5 12C16.5 10.23 15.48 8.70998 14 7.96998V16.02C15.48 15.29 16.5 13.77 16.5 12ZM14 3.22998V5.28998C16.89 6.14998 19 8.82998 19 12C19 15.17 16.89 17.85 14 18.71V20.77C18.01 19.86 21 16.28 21 12C21 7.71998 18.01 4.13998 14 3.22998Z" fill="currentColor"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 24 24" fill="none" class="bunny-lightbox-player__volume-mute-svg"><path d="M16.5 12C16.5 10.23 15.48 8.71 14 7.97V10.18L16.45 12.63C16.48 12.43 16.5 12.22 16.5 12ZM19 12C19 12.94 18.8 13.82 18.46 14.64L19.97 16.15C20.63 14.91 21 13.5 21 12C21 7.72 18.01 4.14 14 3.23V5.29C16.89 6.15 19 8.83 19 12ZM4.27 3L3 4.27L7.73 9H3V15H7L12 20V13.27L16.25 17.52C15.58 18.04 14.83 18.45 14 18.7V20.76C15.38 20.45 16.63 19.81 17.69 18.95L19.73 21L21 19.73L12 10.73L4.27 3ZM12 4L9.91 6.09L12 8.18V4Z" fill="currentColor"></path></svg>
</div>
<div data-player-control="fullscreen" class="bunny-lightbox-player__toggle-fullscreen">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 24 24" fill="none" class="bunny-lightbox-player__fullscreen-scale-svg"><rect x="3" y="14" width="2" height="7" fill="currentColor"></rect><rect x="3" y="3" width="2" height="7" fill="currentColor"></rect><rect x="19" y="3" width="2" height="7" fill="currentColor"></rect><rect x="19" y="14" width="2" height="7" fill="currentColor"></rect><rect x="3" y="19" width="7" height="2" fill="currentColor"></rect><rect x="14" y="19" width="7" height="2" fill="currentColor"></rect><rect x="3" y="3" width="7" height="2" fill="currentColor"></rect><rect x="14" y="3" width="7" height="2" fill="currentColor"></rect></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 24 24" fill="none" class="bunny-lightbox-player__fullscreen-shrink-svg"><rect x="7" y="2" width="2" height="7" fill="currentColor"></rect><rect x="15" y="2" width="2" height="7" fill="currentColor"></rect><rect x="15" y="15" width="2" height="7" fill="currentColor"></rect><rect x="8" y="15" width="2" height="7" fill="currentColor"></rect><rect x="2" y="7" width="7" height="2" fill="currentColor"></rect><rect x="3" y="15" width="7" height="2" fill="currentColor"></rect><rect x="15" y="7" width="7" height="2" fill="currentColor"></rect><rect x="15" y="15" width="7" height="2" fill="currentColor"></rect></svg>
</div>
</div>
</div>
</div>
<div class="bunny-lightbox-player__loading">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="L9" x="0px" y="0px" viewbox="0 0 100 100" enable-background="new 0 0 0 0" xml:space="preserve" width="100%" class="bunny-lightbox-player__loading-svg" fill="none"><path fill="currentColor" d="M73,50c0-12.7-10.3-23-23-23S27,37.3,27,50 M30.9,50c0-10.5,8.5-19.1,19.1-19.1S69.1,39.5,69.1,50"></path><animatetransform attributename="transform" attributetype="XML" type="rotate" dur="1s" from="0 50 50" to="360 50 50" repeatcount="indefinite"></animatetransform></svg>
</div>
</div>
</div>
<button data-bunny-lightbox-control="close" class="bunny-lightbox__close">
<div class="bunny-lightbox__close-bar"></div>
<div class="bunny-lightbox__close-bar is--duplicate"></div>
</button>
</div>/* Lightbox */
.bunny-lightbox {
z-index: 300;
pointer-events: none;
justify-content: center;
align-items: center;
padding: 5vw;
display: flex;
position: fixed;
inset: 0;
overflow: hidden;
}
.bunny-lightbox__calc {
transition: transform 0.3s cubic-bezier(0.625, 0.05, 0, 1), opacity 0.3s linear, visibility 0.3s linear;
width: 100%;
height: 100%;
position: relative;
opacity: 0;
visibility: hidden;
transform: scale(0.9) rotate(0.001deg);
}
[data-bunny-lightbox-status="active"] .bunny-lightbox__calc {
opacity: 1;
visibility: visible;
transform: scale(1) rotate(0.001deg);
}
.bunny-lightbox__dark {
opacity: .95;
pointer-events: auto;
background-color: #191512;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
display: flex;
position: absolute;
top: 0;
left: 0;
}
.bunny-lightbox__close {
z-index: 600;
pointer-events: auto;
border-radius: 50%;
justify-content: center;
align-items: center;
width: 3em;
height: 3em;
display: flex;
position: absolute;
top: 2.5vw;
right: 2.5vw;
}
.bunny-lightbox__close-bar {
background-color: currentColor;
width: 1em;
height: .125em;
position: absolute;
transform: rotate(-45deg);
}
.bunny-lightbox__close-bar.is--duplicate {
transform: rotate(45deg);
}
[data-bunny-lightbox-status] .bunny-lightbox__dark,
[data-bunny-lightbox-status] .bunny-lightbox__close {
transition: opacity 0.3s linear, visibility 0.3s linear;
opacity: 0;
visibility: hidden;
}
[data-bunny-lightbox-status="active"] .bunny-lightbox__dark,
[data-bunny-lightbox-status="active"] .bunny-lightbox__close {
opacity: 1;
visibility: visible;
}
/* Player */
.bunny-lightbox-player {
pointer-events: none;
color: #fff;
isolation: isolate;
border-radius: 1em;
justify-content: center;
align-items: center;
width: 100%;
display: flex;
position: relative;
overflow: hidden;
-webkit-mask-image: radial-gradient(#fff, #000);
mask-image: radial-gradient(#fff, #000);
}
.bunny-lightbox-player__before {
padding-top: 62.5%;
}
/* Animation */
[data-bunny-lightbox-init] :is(.bunny-lightbox-player__placeholder, .bunny-lightbox-player__dark, .bunny-lightbox-player__playpause, .bunny-lightbox-player__loading) {
transition: opacity 0.3s linear, visibility 0.3s linear;
}
/* Placeholder */
.bunny-lightbox-player__placeholder {
object-fit: cover;
width: 100%;
height: 100%;
position: absolute;
}
[data-bunny-lightbox-init][data-player-status="playing"] .bunny-lightbox-player__placeholder,
[data-bunny-lightbox-init][data-player-status="paused"] .bunny-lightbox-player__placeholder,
[data-bunny-lightbox-init][data-player-activated="true"][data-player-status="ready"] .bunny-lightbox-player__placeholder {
opacity: 0;
visibility: hidden;
}
/* Dark Overlay */
.bunny-lightbox-player__dark {
opacity: .1;
background-color: #000;
width: 100%;
height: 100%;
position: absolute;
}
[data-bunny-lightbox-init][data-player-status="paused"] .bunny-lightbox-player__dark,
[data-bunny-lightbox-init][data-player-status="playing"][data-player-hover="active"] .bunny-lightbox-player__dark{
opacity: 0.3;
}
[data-bunny-lightbox-init][data-player-status="playing"] .bunny-lightbox-player__dark {
opacity: 0;
}
.bunny-lightbox-player__video {
width: 100%;
height: 100%;
padding-bottom: 0;
padding-right: 0;
display: block;
position: absolute;
top: 0;
left: 0;
}
/* Play/Pause */
.bunny-lightbox-player__playpause {
pointer-events: auto;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
display: flex;
position: absolute;
}
[data-bunny-lightbox-init][data-player-status="playing"] .bunny-lightbox-player__playpause,
[data-bunny-lightbox-init][data-player-status="loading"] .bunny-lightbox-player__playpause {
opacity: 0;
}
[data-bunny-lightbox-init][data-player-status="playing"][data-player-hover="active"] .bunny-lightbox-player__playpause {
opacity: 1;
}
[data-bunny-lightbox-init][data-player-status="playing"] .bunny-lightbox-player__play-svg,
[data-bunny-lightbox-init][data-player-status="loading"] .bunny-lightbox-player__play-svg {
display: none;
}
[data-bunny-lightbox-init][data-player-status="playing"] .bunny-lightbox-player__pause-svg,
[data-bunny-lightbox-init][data-player-status="loading"] .bunny-lightbox-player__pause-svg{
display: block;
}
/* Loading */
.bunny-lightbox-player__loading {
opacity: 0;
visibility: hidden;
background-color: #00000054;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
display: flex;
position: absolute;
}
[data-bunny-lightbox-init][data-player-status="loading"] .bunny-lightbox-player__loading {
opacity: 1;
visibility: visible;
}
/* Interface */
.bunny-lightbox-player__interface {
flex-flow: column;
justify-content: flex-end;
align-items: baseline;
width: 100%;
height: 100%;
display: flex;
position: absolute;
transition: all 0.6s cubic-bezier(0.625, 0.05, 0, 1);
}
[data-bunny-lightbox-init][data-player-status="playing"] .bunny-lightbox-player__interface,
[data-bunny-lightbox-init][data-player-status="loading"] .bunny-lightbox-player__interface{
opacity: 0;
transform: translateY(1em) rotate(0.001deg);
}
[data-bunny-lightbox-init][data-player-status="playing"][data-player-hover="active"] .bunny-lightbox-player__interface,
[data-bunny-lightbox-init][data-player-status="loading"][data-player-hover="active"] .bunny-lightbox-player__interface {
opacity: 1;
transform: translateY(0em) rotate(0.001deg);
}
.bunny-lightbox-player__interface-bottom {
grid-column-gap: 1em;
grid-row-gap: 1em;
pointer-events: auto;
justify-content: space-between;
align-items: center;
width: 100%;
padding: min(2em, 4vw);
display: flex;
position: relative;
}
.bunny-lightbox-player__toggle-mute,
.bunny-lightbox-player__toggle-fullscreen {
cursor: pointer;
width: 1.5em;
height: 1.5em;
}
.bunny-lightbox-player__timeline {
cursor: pointer;
flex: 1;
align-items: center;
height: 1em;
margin-left: .5em;
margin-right: .5em;
display: flex;
position: relative;
}
[data-bunny-lightbox-init][data-player-status="idle"][data-player-activated="false"] .bunny-lightbox-player__timeline,
[data-bunny-lightbox-init][data-player-status="ready"][data-player-activated="false"] .bunny-lightbox-player__timeline {
pointer-events: none;
}
.bunny-lightbox-player__timeline-progress {
pointer-events: none;
background-color: #ff4c24;
border-radius: 1em;
width: 100%;
height: 100%;
position: absolute;
transform: translateX(-100%);
}
.bunny-lightbox-player__timeline-buffered {
opacity: .2;
pointer-events: none;
background-color: #fff;
border-radius: 1em;
width: 100%;
height: 100%;
position: absolute;
transform: translateX(-100%);
}
.bunny-lightbox-player__timeline-handle {
transition: transform 0.15s ease-in-out;
pointer-events: none;
background-color: #ff4c24;
border-radius: 1em;
width: 1em;
height: 1em;
position: absolute;
top: 50%;
transform: translate(-50%, -50%)scale(0);
}
[data-bunny-lightbox-init][data-timeline-drag="true"] .bunny-lightbox-player__timeline-handle {
transform: translate(-50%, -50%) scale(1);
}
.bunny-lightbox-player__timeline-bar {
border-radius: 1em;
width: 100%;
height: 30%;
position: absolute;
overflow: hidden;
}
.bunny-lightbox-player__time {
grid-column-gap: .125em;
grid-row-gap: .125em;
flex: none;
justify-content: center;
align-items: center;
width: 5.75em;
display: flex;
}
.bunny-lightbox-player__timeline-bg {
background-color: #ffffff26;
border-radius: 1em;
width: 100%;
height: 100%;
position: absolute;
}
.bunny-lightbox-player__toggle-playpause {
cursor: pointer;
width: 1.5em;
height: 1.5em;
}
.bunny-lightbox-player__text {
white-space: nowrap;
margin-bottom: 0;
font-size: .9375em;
line-height: 1;
}
.bunny-lightbox-player__big-btn {
-webkit-backdrop-filter: blur(1em);
backdrop-filter: blur(1em);
cursor: pointer;
background-color: #64646433;
border: 1px solid #ffffff1a;
border-radius: 50%;
justify-content: center;
align-items: center;
width: 6em;
height: 6em;
padding: 2em;
display: flex;
position: relative;
}
.bunny-lightbox-player__loading-svg {
width: 6em;
}
.bunny-lightbox-player__pause-svg {
display: none;
}
.bunny-lightbox-player__interface-fade {
opacity: .5;
background-image: linear-gradient(#0000, #000);
width: 100%;
height: 25%;
position: absolute;
bottom: 0;
}
.bunny-lightbox-player__interface-btns {
grid-column-gap: .5em;
grid-row-gap: .5em;
align-items: center;
display: flex;
}
[data-bunny-lightbox-init][data-player-muted="true"] .bunny-lightbox-player__volume-mute-svg {
display: block;
}
[data-bunny-lightbox-init][data-player-muted="true"] .bunny-lightbox-player__volume-up-svg {
display: none;
}
.bunny-lightbox-player__volume-mute-svg {
display: none;
}
.bunny-lightbox-player__volume-up-svg {
display: block;
}
.bunny-lightbox-player__fullscreen-shrink-svg {
display: none;
}
.bunny-lightbox-player__fullscreen-scale-svg {
display: block;
}
[data-bunny-lightbox-init][data-player-fullscreen="true"] .bunny-lightbox-player__fullscreen-shrink-svg {
display: block;
}
[data-bunny-lightbox-init][data-player-fullscreen="true"] .bunny-lightbox-player__fullscreen-scale-svg {
display: none;
}
/* Cover Mode */
[data-bunny-lightbox-init][data-player-update-size="cover"] {
height: 100%;
top: 0;
left: 0;
position: absolute;
}
[data-bunny-lightbox-init][data-player-update-size="cover"] [data-player-before] {
display: none;
}
[data-bunny-lightbox-init][data-player-update-size="cover"][data-player-fullscreen="false"] .bunny-lightbox-player__video {
object-fit: cover;
}function initBunnyLightboxPlayer() {
var player = document.querySelector('[data-bunny-lightbox-init]');
if (!player) return;
var wrapper = player.closest('[data-bunny-lightbox-status]');
if (!wrapper) return;
var video = player.querySelector('video');
if (!video) return;
try { video.pause(); } catch(_) {}
try { video.removeAttribute('src'); video.load(); } catch(_) {}
// Attribute helpers (collapsed)
function setAttr(el, name, val) {
var str = (typeof val === 'boolean') ? (val ? 'true' : 'false') : String(val);
if (el.getAttribute(name) !== str) el.setAttribute(name, str);
}
function setStatus(s) { setAttr(player, 'data-player-status', s); }
function setMutedState(v) { video.muted = !!v; setAttr(player, 'data-player-muted', video.muted); }
function setFsAttr(v) { setAttr(player, 'data-player-fullscreen', !!v); }
function setActivated(v) { setAttr(player, 'data-player-activated', !!v); }
if (!player.hasAttribute('data-player-activated')) setActivated(false);
// Elements
var timeline = player.querySelector('[data-player-timeline]');
var progressBar = player.querySelector('[data-player-progress]');
var bufferedBar = player.querySelector('[data-player-buffered]');
var handle = player.querySelector('[data-player-timeline-handle]');
var timeDurationEls = player.querySelectorAll('[data-player-time-duration]');
var timeProgressEls = player.querySelectorAll('[data-player-time-progress]');
var playerPlaceholderImg = player.querySelector('[data-bunny-lightbox-placeholder]');
// Flags
var updateSize = player.getAttribute('data-player-update-size'); // "true" | "cover" | "false" | null
var autoplay = player.getAttribute('data-player-autoplay') === 'true';
var initialMuted = player.getAttribute('data-player-muted') === 'true';
var pendingPlay = false;
video.loop = false;
setMutedState(initialMuted);
video.setAttribute('playsinline', '');
video.setAttribute('webkit-playsinline', '');
video.playsInline = true;
if (typeof video.disableRemotePlayback !== 'undefined') video.disableRemotePlayback = true;
if (autoplay) video.autoplay = false;
var isSafariNative = !!video.canPlayType('application/vnd.apple.mpegurl');
var canUseHlsJs = !!(window.Hls && Hls.isSupported()) && !isSafariNative;
// Load/attach only when opened
var isAttached = false;
var currentSrc = '';
var lastPauseBy = '';
var rafId;
var autoStartOnReady = false;
// Clamp setup for [data-bunny-lightbox-calc-height]
function setupLightboxClamp(player, wrapper, video, updateSize) {
var calcBox = wrapper.querySelector('[data-bunny-lightbox-calc-height]');
if (!calcBox) return;
function getRatio() {
if (updateSize === 'cover') return null;
if (updateSize === 'true') {
if (video.videoWidth && video.videoHeight) return video.videoWidth / video.videoHeight;
var before = player.querySelector('[data-player-before]');
if (before && before.style && before.style.paddingTop) {
var pct = parseFloat(before.style.paddingTop);
if (pct > 0) return 100 / pct;
}
var r = player.getBoundingClientRect();
if (r.height > 0) return r.width / r.height;
return 16/9;
}
var beforeFalse = player.querySelector('[data-player-before]');
if (beforeFalse && beforeFalse.style && beforeFalse.style.paddingTop) {
var pad = parseFloat(beforeFalse.style.paddingTop);
if (pad > 0) return 100 / pad;
}
var rb = player.getBoundingClientRect();
if (rb.height > 0) return rb.width / rb.height;
return 16/9;
}
function applyClamp() {
if (updateSize === 'cover') {
calcBox.style.maxWidth = '';
calcBox.style.maxHeight = '';
return;
}
var parent = wrapper;
var cs = getComputedStyle(parent);
var pt = parseFloat(cs.paddingTop) || 0;
var pb = parseFloat(cs.paddingBottom) || 0;
var pl = parseFloat(cs.paddingLeft) || 0;
var pr = parseFloat(cs.paddingRight) || 0;
var cw = (parent.clientWidth - pl - pr);
var ch = (parent.clientHeight - pt - pb);
if (cw <= 0 || ch <= 0) return;
var ratio = getRatio();
if (!ratio) {
calcBox.style.maxWidth = '';
calcBox.style.maxHeight = '';
return;
}
var hIfFullWidth = cw / ratio;
if (hIfFullWidth <= ch) {
calcBox.style.maxWidth = '100%';
calcBox.style.maxHeight = (hIfFullWidth / ch * 100) + '%';
} else {
calcBox.style.maxHeight = '100%';
calcBox.style.maxWidth = ((ch * ratio) / cw * 100) + '%';
}
}
var rafPending = false;
function debouncedApply() {
if (rafPending) return;
if (wrapper.getAttribute('data-bunny-lightbox-status') !== 'active') return;
rafPending = true;
requestAnimationFrame(function(){
rafPending = false;
applyClamp();
});
}
var ro = new ResizeObserver(debouncedApply);
ro.observe(wrapper);
window.addEventListener('resize', debouncedApply);
window.addEventListener('orientationchange', debouncedApply);
if (updateSize === 'true') {
video.addEventListener('loadedmetadata', debouncedApply);
video.addEventListener('loadeddata', debouncedApply);
video.addEventListener('playing', debouncedApply);
}
player._applyClamp = debouncedApply;
debouncedApply();
}
setupLightboxClamp(player, wrapper, video, updateSize);
// Unified attach pipeline
function withAttach(src, onReady) {
if (isSafariNative) {
video.preload = 'auto';
video.src = src;
video.addEventListener('loadedmetadata', onReady, { once: true });
return;
}
if (canUseHlsJs) {
var hls = new Hls({ maxBufferLength: 10 });
player._hls = hls;
hls.attachMedia(video);
hls.on(Hls.Events.MEDIA_ATTACHED, function(){ hls.loadSource(src); });
hls.on(Hls.Events.MANIFEST_PARSED, function(){ onReady(); });
hls.on(Hls.Events.LEVEL_LOADED, function(e, data){
if (data && data.details && isFinite(data.details.totalduration) && timeDurationEls.length) {
setText(timeDurationEls, formatTime(data.details.totalduration));
}
});
return;
}
video.preload = 'auto';
video.src = src;
video.addEventListener('loadedmetadata', onReady, { once: true });
}
function attachMediaFor(src) {
if (currentSrc === src && isAttached) return;
if (player._hls) { try { player._hls.destroy(); } catch(_) {} player._hls = null; }
if (timeDurationEls.length) setText(timeDurationEls, '00:00');
currentSrc = src;
isAttached = true;
withAttach(src, function onReady(){
readyIfIdle(player, pendingPlay);
updateBeforeRatioIOSSafe();
if (typeof player._applyClamp === 'function') player._applyClamp();
if (timeDurationEls.length && video.duration) setText(timeDurationEls, formatTime(video.duration));
if (autoStartOnReady && wrapper.getAttribute('data-bunny-lightbox-status') === 'active') {
setStatus('loading');
safePlay(video);
autoStartOnReady = false;
}
});
}
function ensureOpenUI(isActive) {
var state = isActive ? 'active' : 'not-active';
if (wrapper.getAttribute('data-bunny-lightbox-status') !== state) {
wrapper.setAttribute('data-bunny-lightbox-status', state);
}
if (isActive && typeof player._applyClamp === 'function') player._applyClamp();
}
// Centralized open policy
function isSameSrc(next){ return currentSrc && currentSrc === next; }
function planOnOpen(next) {
var same = isSameSrc(next);
if (!same) {
try { if (!video.paused && !video.ended) video.pause(); } catch(_) {}
if (player._hls) { try { player._hls.destroy(); } catch(_) {} player._hls = null; }
isAttached = false; currentSrc = '';
if (timeDurationEls.length) setText(timeDurationEls, '00:00');
setActivated(false);
setStatus('idle');
attachMediaFor(next);
autoStartOnReady = !!autoplay;
pendingPlay = !!autoplay;
return;
}
autoStartOnReady = !!autoplay;
if (autoplay) {
setStatus('loading');
safePlay(video);
} else {
try { if (!video.paused && !video.ended) video.pause(); } catch(_) {}
setActivated(false);
setStatus('paused');
}
}
// Open/Close API
function openLightbox(src, placeholderUrl) {
if (!src) return;
function activate() {
ensureOpenUI(true);
planOnOpen(src);
}
if (playerPlaceholderImg && placeholderUrl) {
var needsSwap = playerPlaceholderImg.getAttribute('src') !== placeholderUrl;
if (needsSwap || !playerPlaceholderImg.complete || !playerPlaceholderImg.naturalWidth) {
playerPlaceholderImg.onload = function(){ playerPlaceholderImg.onload = null; activate(); };
playerPlaceholderImg.onerror = function(){ playerPlaceholderImg.onerror = null; activate(); };
if (needsSwap) playerPlaceholderImg.setAttribute('src', placeholderUrl);
else playerPlaceholderImg.dispatchEvent(new Event('load'));
} else {
activate();
}
} else {
activate();
}
}
function togglePlay() {
if (video.paused || video.ended) {
pendingPlay = true;
lastPauseBy = '';
setStatus('loading');
safePlay(video);
} else {
lastPauseBy = 'manual';
video.pause();
}
}
function toggleMute() { setMutedState(!video.muted); }
player.addEventListener('click', function(e) {
var btn = e.target.closest('[data-player-control]');
if (!btn || !player.contains(btn)) return;
var type = btn.getAttribute('data-player-control');
if (type === 'play' || type === 'pause' || type === 'playpause') togglePlay();
else if (type === 'mute') toggleMute();
else if (type === 'fullscreen') toggleFullscreen();
});
// Fullscreen helpers
function isFsActive() { return !!(document.fullscreenElement || document.webkitFullscreenElement); }
function enterFullscreen() {
if (player.requestFullscreen) return player.requestFullscreen();
if (video.requestFullscreen) return video.requestFullscreen();
if (video.webkitSupportsFullscreen && typeof video.webkitEnterFullscreen === 'function') return video.webkitEnterFullscreen();
}
function exitFullscreen() {
if (document.exitFullscreen) return document.exitFullscreen();
if (document.webkitExitFullscreen) return document.webkitExitFullscreen();
if (video.webkitDisplayingFullscreen && typeof video.webkitExitFullscreen === 'function') return video.webkitExitFullscreen();
}
function toggleFullscreen() { if (isFsActive() || video.webkitDisplayingFullscreen) exitFullscreen(); else enterFullscreen(); }
document.addEventListener('fullscreenchange', function() { setFsAttr(isFsActive()); });
document.addEventListener('webkitfullscreenchange', function() { setFsAttr(isFsActive()); });
video.addEventListener('webkitbeginfullscreen', function() { setFsAttr(true); });
video.addEventListener('webkitendfullscreen', function() { setFsAttr(false); });
// Time text (not in rAF)
function updateTimeTexts() {
if (timeDurationEls.length) setText(timeDurationEls, formatTime(video.duration));
if (timeProgressEls.length) setText(timeProgressEls, formatTime(video.currentTime));
}
video.addEventListener('timeupdate', updateTimeTexts);
video.addEventListener('loadedmetadata', function(){ updateTimeTexts(); updateBeforeRatioIOSSafe(); });
video.addEventListener('loadeddata', function(){ updateBeforeRatioIOSSafe(); });
video.addEventListener('playing', function(){ updateBeforeRatioIOSSafe(); });
video.addEventListener('durationchange', updateTimeTexts);
// rAF visuals (progress + handle only)
function updateProgressVisuals() {
if (!video.duration) return;
var playedPct = (video.currentTime / video.duration) * 100;
if (progressBar) progressBar.style.transform = 'translateX(' + (-100 + playedPct) + '%)';
if (handle) handle.style.left = pctClamp(playedPct) + '%';
}
function pctClamp(p) { return p < 0 ? 0 : p > 100 ? 100 : p; }
function loop() {
updateProgressVisuals();
if (!video.paused && !video.ended) rafId = requestAnimationFrame(loop);
}
// Buffered bar (not in rAF)
function updateBufferedBar() {
if (!bufferedBar || !video.duration || !video.buffered.length) return;
var end = video.buffered.end(video.buffered.length - 1);
var buffPct = (end / video.duration) * 100;
bufferedBar.style.transform = 'translateX(' + (-100 + buffPct) + '%)';
}
video.addEventListener('progress', updateBufferedBar);
video.addEventListener('loadedmetadata', updateBufferedBar);
video.addEventListener('durationchange', updateBufferedBar);
// Media event wiring
video.addEventListener('play', function() { setActivated(true); cancelAnimationFrame(rafId); loop(); setStatus('playing'); });
video.addEventListener('playing', function() { pendingPlay = false; setStatus('playing'); });
video.addEventListener('pause', function() { pendingPlay = false; cancelAnimationFrame(rafId); updateProgressVisuals(); setStatus('paused'); });
video.addEventListener('waiting', function() { setStatus('loading'); });
video.addEventListener('canplay', function() { readyIfIdle(player, pendingPlay); });
// Video ended
video.addEventListener('ended', function () {
pendingPlay = false;
cancelAnimationFrame(rafId);
updateProgressVisuals();
setActivated(false);
video.currentTime = 0;
// Exit fullscreen if active
if (document.fullscreenElement || document.webkitFullscreenElement || video.webkitDisplayingFullscreen) {
if (document.exitFullscreen) document.exitFullscreen();
else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
else if (video.webkitExitFullscreen) video.webkitExitFullscreen();
}
closeLightbox();
});
// Scrubbing (pointer events)
if (timeline) {
var dragging = false, wasPlaying = false, targetTime = 0, lastSeekTs = 0, seekThrottle = 180, rect = null;
window.addEventListener('resize', function() { if (!dragging) rect = null; });
function getFractionFromX(x) {
if (!rect) rect = timeline.getBoundingClientRect();
var f = (x - rect.left) / rect.width; if (f < 0) f = 0; if (f > 1) f = 1; return f;
}
function previewAtFraction(f) {
if (!video.duration) return;
var pct = f * 100;
if (progressBar) progressBar.style.transform = 'translateX(' + (-100 + pct) + '%)';
if (handle) handle.style.left = pct + '%';
if (timeProgressEls.length) setText(timeProgressEls, formatTime(f * video.duration));
}
function maybeSeek(now) {
if (!video.duration) return;
if ((now - lastSeekTs) < seekThrottle) return;
lastSeekTs = now; video.currentTime = targetTime;
}
function onPointerDown(e) {
if (!video.duration) return;
dragging = true; wasPlaying = !video.paused && !video.ended; if (wasPlaying) video.pause();
player.setAttribute('data-timeline-drag', 'true'); rect = timeline.getBoundingClientRect();
var f = getFractionFromX(e.clientX); targetTime = f * video.duration; previewAtFraction(f); maybeSeek(performance.now());
timeline.setPointerCapture && timeline.setPointerCapture(e.pointerId);
window.addEventListener('pointermove', onPointerMove, { passive: false });
window.addEventListener('pointerup', onPointerUp, { passive: true });
e.preventDefault();
}
function onPointerMove(e) {
if (!dragging) return;
var f = getFractionFromX(e.clientX); targetTime = f * video.duration; previewAtFraction(f); maybeSeek(performance.now()); e.preventDefault();
}
function onPointerUp() {
if (!dragging) return;
dragging = false; player.setAttribute('data-timeline-drag', 'false'); rect = null; video.currentTime = targetTime;
if (wasPlaying) safePlay(video); else { updateProgressVisuals(); updateTimeTexts(); }
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
}
timeline.addEventListener('pointerdown', onPointerDown, { passive: false });
if (handle) handle.addEventListener('pointerdown', onPointerDown, { passive: false });
}
// Hover/idle detection (pointer-based)
var hoverTimer;
var hoverHideDelay = 3000;
function setHover(state) {
if (player.getAttribute('data-player-hover') !== state) {
player.setAttribute('data-player-hover', state);
}
}
function scheduleHide() { clearTimeout(hoverTimer); hoverTimer = setTimeout(function() { setHover('idle'); }, hoverHideDelay); }
function wakeControls() { setHover('active'); scheduleHide(); }
player.addEventListener('pointerdown', wakeControls);
document.addEventListener('fullscreenchange', wakeControls);
document.addEventListener('webkitfullscreenchange', wakeControls);
var trackingMove = false;
function onPointerMoveGlobal(e) {
var r = player.getBoundingClientRect();
if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) wakeControls();
}
player.addEventListener('pointerenter', function() {
wakeControls();
if (!trackingMove) { trackingMove = true; window.addEventListener('pointermove', onPointerMoveGlobal, { passive: true }); }
});
player.addEventListener('pointerleave', function() {
setHover('idle'); clearTimeout(hoverTimer);
if (trackingMove) { trackingMove = false; window.removeEventListener('pointermove', onPointerMoveGlobal); }
});
// Close Function
function closeLightbox() {
ensureOpenUI(false);
var hasPlayed = false;
try {
if (video.played && video.played.length) {
for (var i = 0; i < video.played.length; i++) {
if (video.played.end(i) > 0) { hasPlayed = true; break; }
}
} else {
hasPlayed = video.currentTime > 0;
}
} catch (_) {}
try { if (!video.paused && !video.ended) video.pause(); } catch (_) {}
setActivated(false);
setStatus(hasPlayed ? 'paused' : 'idle');
}
// Global open/close controls + ESC
document.addEventListener('click', function(e) {
var openBtn = e.target.closest('[data-bunny-lightbox-control="open"]');
if (openBtn) {
var src = openBtn.getAttribute('data-bunny-lightbox-src') || '';
if (!src) return;
var imgEl = openBtn.querySelector('[data-bunny-lightbox-placeholder]');
var placeholderUrl = imgEl ? imgEl.getAttribute('src') : '';
openLightbox(src, placeholderUrl);
return;
}
var closeBtn = e.target.closest('[data-bunny-lightbox-control="close"]');
if (closeBtn) {
var closeInWrapper = closeBtn.closest('[data-bunny-lightbox-status]');
if (closeInWrapper === wrapper) closeLightbox();
return;
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeLightbox();
});
// Helper: time/text/meta/ratio utilities
function pad2(n) { return (n < 10 ? '0' : '') + n; }
function formatTime(sec) {
if (!isFinite(sec) || sec < 0) return '00:00';
var s = Math.floor(sec), h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;
return h > 0 ? (h + ':' + pad2(m) + ':' + pad2(r)) : (pad2(m) + ':' + pad2(r));
}
function setText(nodes, text) { nodes.forEach(function(n){ n.textContent = text; }); }
// Helper: Choose best HLS level by resolution
function bestLevel(levels) {
if (!levels || !levels.length) return null;
return levels.reduce(function(a, b) { return ((b.width||0) > (a.width||0)) ? b : a; }, levels[0]);
}
// Helper: Safe programmatic play
function safePlay(video) {
var p = video.play();
if (p && typeof p.then === 'function') p.catch(function(){});
}
// Helper: Ready status guard
function readyIfIdle(player, pendingPlay) {
if (!pendingPlay &&
player.getAttribute('data-player-activated') !== 'true' &&
player.getAttribute('data-player-status') === 'idle') {
player.setAttribute('data-player-status', 'ready');
}
}
// Helper: Ratio Setter
function setBeforeRatio(player, updateSize, w, h) {
if (updateSize !== 'true' || !w || !h) return;
var before = player.querySelector('[data-player-before]');
if (!before) return;
before.style.paddingTop = (h / w * 100) + '%';
}
function maybeSetRatioFromVideo(player, updateSize, video) {
if (updateSize !== 'true') return;
var before = player.querySelector('[data-player-before]');
if (!before) return;
var hasPad = before.style.paddingTop && before.style.paddingTop !== '0%';
if (!hasPad && video.videoWidth && video.videoHeight) {
setBeforeRatio(player, updateSize, video.videoWidth, video.videoHeight);
}
}
// Helper: robust ratio setter for iOS Safari (with HLS fallback)
function updateBeforeRatioIOSSafe() {
if (updateSize !== 'true') return;
var before = player.querySelector('[data-player-before]');
if (!before) return;
function apply(w, h) {
if (!w || !h) return;
before.style.paddingTop = (h / w * 100) + '%';
if (typeof player._applyClamp === 'function') player._applyClamp();
}
if (video.videoWidth && video.videoHeight) { apply(video.videoWidth, video.videoHeight); return; }
if (player._hls && player._hls.levels && player._hls.levels.length) {
var lvls = player._hls.levels;
var best = lvls.reduce(function(a, b) { return ((b.width||0) > (a.width||0)) ? b : a; }, lvls[0]);
if (best && best.width && best.height) { apply(best.width, best.height); return; }
}
requestAnimationFrame(function () {
if (video.videoWidth && video.videoHeight) { apply(video.videoWidth, video.videoHeight); return; }
var master = (typeof currentSrc === 'string' && currentSrc) ? currentSrc : '';
if (!master || master.indexOf('blob:') === 0) {
var attrSrc = player.getAttribute('data-bunny-lightbox-src') || player.getAttribute('data-player-src') || '';
if (attrSrc && attrSrc.indexOf('blob:') !== 0) master = attrSrc;
}
if (!master || !/^https?:/i.test(master)) return;
fetch(master, { credentials: 'omit', cache: 'no-store' })
.then(function (r) { if (!r.ok) throw new Error(); return r.text(); })
.then(function (txt) {
var lines = txt.split(/\r?\n/);
var bestW = 0, bestH = 0, last = null;
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.indexOf('#EXT-X-STREAM-INF:') === 0) {
last = line;
} else if (last && line && line[0] !== '#') {
var m = /RESOLUTION=(\d+)x(\d+)/.exec(last);
if (m) {
var W = parseInt(m[1], 10), H = parseInt(m[2], 10);
if (W > bestW) { bestW = W; bestH = H; }
}
last = null;
}
}
if (bestW && bestH) apply(bestW, bestH);
})
.catch(function () {});
});
}
}
// Initialize Bunny HTML HLS Lightbox
document.addEventListener('DOMContentLoaded', function() {
initBunnyLightboxPlayer();
});Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| data-bunny-lightbox-status | string | not-active | Set on the lightbox wrapper. Controls visibility of the lightbox. Set to "active" to open and "not-active" to close. |
| data-bunny-lightbox-init | boolean | Marks the player element that the script initialises. Required on the inner player div. | |
| data-bunny-lightbox-control | string | Add to any clickable element. Use "open" to open the lightbox and "close" to close it. | |
| data-bunny-lightbox-src | string | The HLS playlist URL (.m3u8) for the video to load. Add to the element with data-bunny-lightbox-control="open". | |
| data-bunny-lightbox-placeholder | boolean | Add to an <img> inside the open button to provide a placeholder image. Can be hidden with display: none — used as data only. | |
| data-bunny-lightbox-calc-height | boolean | Marks the sizing container inside the lightbox wrapper. The script uses this to clamp the player to the correct aspect ratio. | |
| data-player-status | string | idle | Reflects live playback state. Values: idle, ready, loading, playing, paused. Updated automatically — never set manually. |
| data-player-activated | boolean | false | Becomes true after first play, resets to false when the video ends. Use to hide the placeholder only after playback has started. |
| data-player-autoplay | boolean | false | Set to "true" to start playing automatically when the lightbox is opened. |
| data-player-muted | boolean | false | Reflects and controls the mute state. Updated automatically when mute is toggled. |
| data-player-fullscreen | boolean | false | Reflects fullscreen state. Updated automatically when the player enters or exits fullscreen. |
| data-player-hover | string | idle | Reflects pointer interaction state. Values: active or idle. Controls are shown when active and fade out after inactivity. |
| data-player-update-size | string | true | Controls aspect ratio behaviour. "true" calculates ratio from the video and applies padding-top. "cover" fills the container. |
| data-player-control | string | Add to UI elements inside the player. Accepted values: playpause, play, pause, mute, fullscreen. | |
| data-player-timeline | boolean | Marks the interactive seek area. Users can click or drag to scrub through the video. | |
| data-player-progress | boolean | The played-progress bar element. Updated via CSS transform translateX. | |
| data-player-buffered | boolean | The buffered-progress bar element. Shows how much of the video is buffered ahead. | |
| data-player-timeline-handle | boolean | The draggable scrubber handle. Appears during drag (data-timeline-drag="true"). | |
| data-timeline-drag | boolean | false | Set to "true" while the user is dragging the timeline handle. Use to style the timeline during scrubbing. |
| data-player-time-duration | boolean | Displays the total video duration, formatted as mm:ss or hh:mm:ss. Updated automatically. | |
| data-player-time-progress | boolean | Displays the current playback position. Updated continuously during playback and while scrubbing. | |
| data-player-before | boolean | Receives an inline padding-top when data-player-update-size="true" to reserve space for the video's aspect ratio. |
Notes
- •This player only works with HLS (.m3u8) sources. It will not play regular MP4 files.
- •On Safari (macOS and iOS), native HLS support is used automatically instead of hls.js.
- •The HLS.js script must be loaded before the player script runs.
- •Each open button needs its own data-bunny-lightbox-src pointing to the correct .m3u8 playlist URL.
- •A placeholder image inside the open button (data-bunny-lightbox-placeholder) can be hidden with display: none — it is used as data only.
- •Pressing Escape always closes the lightbox.
Guide
Implementation
For hosting .m3u8 HLS files, Bunny.net is recommended. It provides a straightforward interface for uploading and managing videos, automatically generating the HLS streams needed by this player. This player is built exclusively for HTTP Live Streaming (HLS) sources which use a .m3u8 playlist file. Playback is handled through hls.js on most browsers, or through Safari's native HLS support on macOS and iOS.
Open the lightbox
Add [data-bunny-lightbox-control="open"] to any element. When the user clicks it, the lightbox opens. Set the HLS playlist URL on [data-bunny-lightbox-src]. To show a placeholder image while the video loads, add an <img> with [data-bunny-lightbox-placeholder] as a child of the open button — it can be hidden with display: none.
<button data-bunny-lightbox-control="open" data-bunny-lightbox-src="https://vz-6ed806ff-5e5.b-cdn.net/b6a663de-07c1-4c37-8bb6-0e79fef7fb3c/playlist.m3u8">
<img data-bunny-lightbox-placeholder="" src="image.jpg" alt="">
<span>Open Video</span>
</button>Close the lightbox
Add [data-bunny-lightbox-control="close"] to any element. Clicking it pauses the video and closes the lightbox. Pressing the Escape key also closes it.
<button data-bunny-lightbox-control="close">Close Video</button>Lightbox container
The wrapper uses [data-bunny-lightbox-status="not-active"]. When set to "active" the lightbox opens. Inside it, [data-bunny-lightbox-calc-height] manages the player sizing. The wrapper also holds the dark background overlay and the close button.
<div data-bunny-lightbox-status="not-active">
<div class="dark-background" data-bunny-lightbox-control="close"></div>
<div data-bunny-lightbox-calc-height>
<!-- Video Player -->
<div class="bunny-player" data-bunny-lightbox-init></div>
</div>
</div>Status
The [data-player-status] attribute reflects live playback state and is never set manually. Use it in CSS to show or hide UI elements: idle (initialised, no media yet), ready (metadata loaded, ready to play), loading (buffering), playing (active playback), paused (stopped by user or ended).
Autoplay
Set [data-player-autoplay="true"] on the player element to start playback automatically when the lightbox opens.
Play / Pause
Add [data-player-control="playpause"] to a button inside the player to toggle playback. Use [data-player-control="play"] or [data-player-control="pause"] if you need separate controls.
Mute
Add [data-player-control="mute"] to toggle the mute state. The attribute [data-player-muted="true|false"] updates automatically so you can style the icon accordingly.
Fullscreen
Add [data-player-control="fullscreen"] to toggle fullscreen. The attribute [data-player-fullscreen="true|false"] updates automatically.
Timeline
Mark the seek area with [data-player-timeline]. Add [data-player-progress] for the played bar, [data-player-buffered] for the buffered bar, and [data-player-timeline-handle] for the draggable scrubber knob. While dragging, [data-timeline-drag="true"] is set on the player.
Duration & Progress
Place [data-player-time-duration] and [data-player-time-progress] anywhere in your UI. Both update automatically — duration shows total length, progress shows current position.
Hover
[data-player-hover="active|idle"] reflects pointer activity. Controls fade in when active and hide after a few seconds of inactivity. Use it in CSS to animate your interface.
Aspect ratio
Set [data-player-update-size="true"] to have the script calculate the video's aspect ratio and apply it as padding-top on [data-player-before]. Set it to "cover" to let CSS fill the container instead.