1// Customer testimonial videos: muted preview cards that play while on screen, plus a 2// modal player with sound. Cards are rendered server-side by 3// templates/macros/testimonials.html; clips stream from Cloudflare Stream (HLS). 4// 5// Safari plays HLS natively. Other browsers need hls.js, which is loaded on demand 6// the first time a card comes near the viewport, so pages pay nothing up front. 7(function () { 8 var cards = document.querySelectorAll('.vcard[data-src]'); 9 var modal = document.getElementById('vmodal'); 10 if (!cards.length || !modal) { return; } 11 12 var HLS_URL = document.currentScript && document.currentScript.dataset.hls; 13 var HLS_NATIVE = !!document.createElement('video').canPlayType('application/vnd.apple.mpegurl'); 14 // No capLevelToPlayerSize: cards crop a 16:9 source into a 9:16 box, so the element 15 // box is ~3x smaller than the pixels actually shown and it would pin them to 240p. 16 // No startLevel:-1 either - that lets ABR pick the opening level, which is the ramp 17 // from 240p we are avoiding. 18 var HLS_CFG = { maxBufferLength: 10, maxMaxBufferLength: 20, autoStartLoad: false, 19 abrEwmaDefaultEstimate: 5000000, abrBandWidthUpFactor: 0.9 }; 20 21 var hlsReady = null; 22 function loadHls() { 23 if (!hlsReady) { 24 hlsReady = new Promise(function (resolve) { 25 var s = document.createElement('script'); 26 s.src = HLS_URL; 27 s.onload = resolve; 28 s.onerror = resolve; 29 document.head.appendChild(s); 30 }); 31 } 32 return hlsReady; 33 } 34 35 function attach(v, url) { 36 if (!url || v.dataset.cfAttached === url) { return Promise.resolve(); } 37 detach(v); 38 v.dataset.cfAttached = url; 39 if (HLS_NATIVE) { v.src = url; return Promise.resolve(); } 40 return loadHls().then(function () { 41 // the card may have scrolled away (or the modal closed) while hls.js loaded 42 if (v.dataset.cfAttached !== url || !window.Hls || !Hls.isSupported()) { return; } 43 var h = new Hls(HLS_CFG); 44 v._hls = h; 45 h.on(Hls.Events.MANIFEST_PARSED, function () { 46 h.startLevel = h.levels.length - 1; // levels sort ascending by bitrate; open on the best 47 h.startLoad(); 48 }); 49 h.loadSource(url); 50 h.attachMedia(v); 51 }); 52 } 53 54 function detach(v) { 55 if (v._hls) { try { v._hls.destroy(); } catch (e) {} v._hls = null; } 56 delete v.dataset.cfAttached; 57 if (v.getAttribute('src')) { v.removeAttribute('src'); v.load(); } 58 } 59 60 function play(v) { 61 var p = v.play(); 62 if (p && p.catch) { p.catch(function () {}); } 63 } 64 65 // --- Cards: hold a player only near the viewport, play muted while visible --- 66 var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 67 var ioLoad = new IntersectionObserver(function (entries) { 68 entries.forEach(function (e) { 69 var v = e.target.querySelector('video'); 70 if (e.isIntersecting) { attach(v, e.target.dataset.src); } else { detach(v); } 71 }); 72 }, { rootMargin: '600px' }); 73 var ioPlay = new IntersectionObserver(function (entries) { 74 entries.forEach(function (e) { 75 var v = e.target.querySelector('video'); 76 if (e.isIntersecting && !reduceMotion && !modal.classList.contains('open')) { 77 attach(v, e.target.dataset.src).then(function () { play(v); }); 78 } else { 79 v.pause(); 80 } 81 }); 82 }, { threshold: 0.35 }); 83 84 cards.forEach(function (card) { 85 card.addEventListener('click', function () { openModal(card); }); 86 card.addEventListener('keydown', function (e) { 87 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openModal(card); } 88 }); 89 ioLoad.observe(card); 90 ioPlay.observe(card); 91 }); 92 93 document.querySelectorAll('.vrail-btn').forEach(function (b) { 94 b.addEventListener('click', function () { 95 document.getElementById(b.dataset.rail).scrollBy({ left: (236 + 18) * 2 * (+b.dataset.dir), behavior: 'smooth' }); 96 }); 97 }); 98 99 // --- Modal player: steps through the clips of the section that was clicked --- 100 var mv = document.getElementById('vmvideo'); 101 var bar = document.getElementById('vbar'); 102 var mq = document.getElementById('vmquote'); 103 var mw = document.getElementById('vmwho'); 104 var mc = document.getElementById('vmclinic'); 105 var mcount = document.getElementById('vcount'); 106 var mmute = document.getElementById('vmmute'); 107 var ICON_ON = '<svg viewBox="0 0 24 24">
107<path d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z"/></svg>'; 108 var ICON_OFF = '<svg viewBox="0 0 24 24"><path d="M3 10v4h4l5 5V5L7 10H3zm13.6 2 2.5-2.5-1.4-1.4-2.5 2.5-2.5-2.5-1.4 1.4L13.8 12l-2.5 2.5 1.4 1.4 2.5-2.5 2.5 2.5 1.4-1.4L17.6 12z"/></svg>'; 109 var list = [], cur = 0, opener = null; 110 111 function show(i) { 112 cur = (i + list.length) % list.length; 113 var c = list[cur]; 114 mv.poster = c.querySelector('video').poster; 115 mv.muted = false; 116 mmute.innerHTML = ICON_ON; 117 mq.textContent = c.dataset.quote.replace(/[â]/g, '"'); 118 mw.textContent = c.dataset.who; 119 mc.textContent = c.dataset.clinic; 120 mcount.textContent = (cur + 1) + ' / ' + list.length; 121 bar.style.width = '0%'; 122 // attach only once the modal is laid out, so the player sees real dimensions 123 attach(mv, c.dataset.src).then(function () { play(mv); }); 124 } 125 126 function openModal(card) { 127 list = Array.prototype.slice.call(card.parentNode.querySelectorAll('.vcard[data-src]')); 128 opener = card; 129 cards.forEach(function (c) { c.querySelector('video').pause(); }); 130 modal.classList.add('open'); 131 document.body.style.overflow = 'hidden'; 132 show(list.indexOf(card)); 133 document.getElementById('vclose').focus(); 134 } 135 136 function closeModal() { 137 modal.classList.remove('open'); 138 mv.pause(); 139 detach(mv); 140 document.body.style.overflow = ''; 141 if (opener) { opener.focus(); } 142 } 143 144 document.getElementById('vclose').onclick = closeModal; 145 document.getElementById('vmnext').onclick = function () { show(cur + 1); }; 146 document.getElementById('vmprev').onclick = function () { show(cur - 1); }; 147 modal.addEventListener('click', function (e) { if (e.target === modal) { closeModal(); } }); 148 mv.addEventListener('click', function () { if (mv.paused) { play(mv); } else { mv.pause(); } }); 149 mv.addEventListener('timeupdate', function () { bar.style.width = ((mv.currentTime / mv.duration * 100) || 0) + '%'; }); 150 mv.addEventListener('ended', function () { show(cur + 1); }); 151 document.getElementById('vprogress').addEventListener('click', function (e) { 152 var r = e.currentTarget.getBoundingClientRect(); 153 mv.currentTime = (e.clientX - r.left) / r.width * mv.duration; 154 }); 155 mmute.onclick = function () { mv.muted = !mv.muted; mmute.innerHTML = mv.muted ? ICON_OFF : ICON_ON; }; 156 document.addEventListener('keydown', function (e) { 157 if (!modal.classList.contains('open')) { return; } 158 if (e.key === 'Escape') { closeModal(); } 159 if (e.key === 'ArrowRight') { show(cur + 1); } 160 if (e.key === 'ArrowLeft') { show(cur - 1); } 161 if (e.key === ' ') { e.preventDefault(); if (mv.paused) { play(mv); } else { mv.pause(); } } 162 }); 163})(); 164 165// "Vis alle citater" toggle on quote walls with hidden extra cards 166document.querySelectorAll('[data-qmore]').forEach(function (btn) { 167 btn.addEventListener('click', function () { 168 var open = document.getElementById(btn.dataset.qmore).classList.toggle('open') === false; 169 btn.textContent = open ? 'Vis alle citater' : 'Vis færre'; 170 btn.setAttribute('aria-expanded', String(!open)); 171 }); 172});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.