PageSourceSearch

https://opkald.ai/static/js/testimonials.js?v=1791185699

js opkald.ai collected 2026-10-05 13:44:41 UTC 7,530 bytes, 172 lines download raw bytes

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.