1/* global Vimeo, Blob, URL, alert */ 2(function (Drupal) { 3 'use strict'; 4 5 const instances = new Map(); 6 7 function formatTime(seconds) { 8 if (seconds == null || isNaN(seconds)) { 9 return '0:00'; 10 } 11 12 const mins = Math.floor(seconds / 60); 13 const secs = Math.floor(seconds % 60); 14 15 return `${mins}:${secs.toString().padStart(2, '0')}`; 16 } 17 18 function renderTranscript(instance) { 19 const { transcriptEl, cues } = instance; 20 21 if (cues.length === 0) { 22 transcriptEl.innerHTML = '<p class="transcript-placeholder">No transcript available.</p>'; 23 return; 24 } 25 26 transcriptEl.innerHTML = cues.map((cue, index) => ` 27 <div class="transcript-cue" data-index="${index}" data-start="${cue.start}"> 28 <span class="time">${formatTime(cue.start)}</span> 29 <span class="text">${cue.text}</span> 30 </div> 31 `).join(''); 32 33 transcriptEl.querySelectorAll('.transcript-cue').forEach(el => { 34 el.addEventListener('click', () => { 35 const time = parseFloat(el.dataset.start); 36 instance.player.setCurrentTime(time); 37 instance.player.play(); 38 }); 39 }); 40 } 41 42 function highlightCurrentCue(instance, currentTime) { 43 const { transcriptEl, autoScrollEl, cues } = instance; 44 const cueElements = transcriptEl.querySelectorAll('.transcript-cue'); 45 let activeElement = null; 46 let activeIndex = -1; 47 48 for (let i = 0; i < cues.length; i++) { 49 const cue = cues[i]; 50 const nextCue = cues[i + 1]; 51 const isActive = currentTime >= cue.start && (!nextCue || currentTime < nextCue.start); 52 53 if (isActive) { 54 activeIndex = i; 55 break; 56 } 57 } 58 59 cueElements.forEach((el, i) => { 60 if (i === activeIndex) { 61 el.classList.add('active'); 62 activeElement = el; 63 } 64 else { 65 el.classList.remove('active'); 66 } 67 }); 68 69 if (activeElement && autoScrollEl && autoScrollEl.checked) { 70 const containerHeight = transcriptEl.clientHeight; 71 const elementTop = activeElement.offsetTop; 72 const elementHeight = activeElement.clientHeight; 73 const scrollPosition = elementTop - (containerHeight / 2) + (elementHeight / 2); 74 transcriptEl.scrollTo({ top: scrollPosition, behavior: 'smooth' }); 75 } 76 } 77 78 /** 79 * Get the base URL for API calls. 80 * 81 * @return {string} Base URL (empty for Drupal, full URL for Storybook). 82 */ 83 function getApiBaseUrl() { 84 if (typeof drupalSettings !== 'undefined' && drupalSettings.path?.baseUrl) { 85 return drupalSettings.path.baseUrl.replace(/\/$/, ''); 86 } 87 if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { 88 return 'https://lek-redux.ddev.site'; 89 } 90 return ''; 91 } 92 93 /** 94 * Fetch embed HTML from backend API. 95 * 96 * @param {string} videoId - The Vimeo video ID. 97 * @return {Promise<object|null>} Embed data or null on error. 98 */ 99 async function fetchEmbed(videoId) { 100 try { 101 const baseUrl = getApiBaseUrl(); 102 const response = await fetch(`${baseUrl}/api/vimeo/${videoId}/embed`, { credentials: 'omit' }); 103 const data = await response.json(); 104 105 if (data.error) { 106 console.error('Embed fetch error:', data.error); 107 return null; 108 } 109 110 return data; 111 } 112 catch (error) { 113 console.error('Failed to fetch embed:', error); 114 return null; 115 } 116 } 117 118 /** 119 * Fetch transcript from backend API. 120 * 121 * @param {object} instance - The video instance. 122 * @param {string} [language] - Optional language code (e.g., 'en', 'fr'). 123 */ 124 async function fetchTranscript(instance, language) { 125 const { videoId, transcriptEl } = instance; 126 127 // Avoid redundant fetches once a transcript is loaded, unless a 128 // different caption language was explicitly selected. 129 if (instance.transcriptLoaded && (!language || language === instance.language)) { 130 return; 131 } 132 133 transcriptEl.innerHTML = '<p class="transcript-placeholder">Loading transcript...</p>'; 134 135 try { 136 const baseUrl = getApiBaseUrl(); 137 let url = `${baseUrl}/api/vimeo/${videoId}/transcript`; 138 if (language) { 139 url += `?lang=${encodeURIComponent(language)}`; 140 } 141 const response = await fetch(url, { credentials: 'omit' }); 142 const data = await response.json(); 143 144 if (data.error) { 145 transcriptEl.innerHTML = '<p class="transcript-placeholder">Could not load transcript.</p>'; 146 return; 147 } 148 149 instance.transcriptLoaded = true; 150 151 if (!data.cues || data.cues.length === 0) { 152 transcriptEl.innerHTML = '<p class="transcript-placeholder">No transcript available for this video.</p>'; 153 return; 154 } 155 156 instance.cues = data.cues; 157 instance.language = data.language; 158 renderTranscript(instance); 159 } 160 catch (error) { 161 console.error('Failed to fetch transcript:', error); 162 transcriptEl.innerHTML = '<p class="transcript-placeholder">Transcript not available.</p>'; 163 } 164 } 165 166 async function initPlayer(instance) { 167 const { container, videoId } = instance; 168 169 const playerEl = container.querySelector('.player'); 170 if (!playerEl) { 171 return; 172 } 173 174 instance.cues = []; 175 176 const embedData = await fetchEmbed(videoId); 177 if (!embedData?.embed) { 178 return; 179 } 180 181 playerEl.innerHTML = embedData.embed; 182 183 if (instance.transcriptContainer) { 184 instance.transcriptContainer.hidden = !embedData.hasTranscript; 185 } 186 187 const iframe = playerEl.querySelector('iframe'); 188 if (!iframe) { 189 return; 190 } 191 192 instance.player = new Vimeo.Player(iframe); 193 194 instance.player.on('timeupdate', data => { 195 highlightCurrentCue(instance, data.seconds); 196 }); 197 198 instance.player.on('error', error => { 199 console.error('Vimeo player error:', error.message); 200 }); 201 202 instance.player.on('texttrackchange', data => { 203 if (data.kind === 'captions' || data.kind === 'subtitles') { 204 if (instance.transcriptContainer) { 205 instance.transcriptContainer.hidden = false;
206 } 207 fetchTranscript(instance, data.language); 208 } 209 }); 210 } 211 212 function downloadTranscript(instance) { 213 if (!instance || instance.cues.length === 0) { 214 alert('No transcript available to download.'); 215 return; 216 } 217 218 const content = instance.cues 219 .map(cue => `[${formatTime(cue.start)}] ${cue.text}`) 220 .join('\n'); 221 222 const blob = new Blob([content], { type: 'text/plain' }); 223 const url = URL.createObjectURL(blob); 224 const a = document.createElement('a'); 225 a.href = url; 226 a.download = `transcript-${instance.videoId}.txt`; 227 a.click(); 228 URL.revokeObjectURL(url); 229 } 230 231 Drupal.behaviors.vimeoVideo = { 232 attach: function (context) { 233 const containers = context.querySelectorAll('.player-container[data-video-id]'); 234 235 containers.forEach(container => { 236 if (container.dataset.vimeoInitialized) { 237 return; 238 } 239 240 container.dataset.vimeoInitialized = 'true'; 241 242 const videoId = container.dataset.videoId; 243 const wrapper = container.closest('.vimeo-video'); 244 const transcriptEl = wrapper?.querySelector('.transcript-content'); 245 const autoScrollEl = wrapper?.querySelector('.autoscroll input[type="checkbox"]'); 246 247 const transcriptContainer = wrapper?.querySelector('.transcript-container'); 248 249 const instance = { 250 videoId, 251 container, 252 wrapper, 253 transcriptEl, 254 autoScrollEl, 255 transcriptContainer, 256 player: null, 257 cues: [], 258 language: null, 259 transcriptLoaded: false, 260 }; 261 262 instances.set(videoId, instance); 263 initPlayer(instance); 264 }); 265 }, 266 }; 267 268 Drupal.behaviors.vimeoTranscriptAccordion = { 269 attach: function (context) { 270 const toggleButtons = context.querySelectorAll('.transcript-toggle'); 271 272 toggleButtons.forEach(button => { 273 if (button.dataset.accordionInitialized) { 274 return; 275 } 276 button.dataset.accordionInitialized = 'true'; 277 278 const container = button.closest('.transcript-container'); 279 const content = container?.querySelector('.accordion-content'); 280 const chevron = button.querySelector('.transcript-chevron'); 281 282 if (!content) { 283 return; 284 } 285 286 button.addEventListener('click', async () => { 287 const isExpanded = button.getAttribute('aria-expanded') === 'true'; 288 289 button.setAttribute('aria-expanded', !isExpanded); 290 291 // Toggle inert attribute to prevent focus on hidden content 292 if (isExpanded) { 293 content.setAttribute('inert', ''); 294 } 295 else { 296 content.removeAttribute('inert'); 297 } 298 299 if (chevron) { 300 chevron.classList.toggle('rotate-180', isExpanded); 301 } 302 303 if (!isExpanded) { 304 const wrapper = button.closest('.vimeo-video'); 305 const playerContainer = wrapper?.querySelector('.player-container[data-video-id]'); 306 const instance = instances.get(playerContainer?.dataset.videoId); 307 308 content.style.maxHeight = `${content.scrollHeight}px`; 309 310 if (instance && !instance.transcriptLoaded) { 311 await fetchTranscript(instance); 312 // Recalculate now that the transcript content has replaced 313 // the loading placeholder, so the accordion isn't clipped. 314 if (button.getAttribute('aria-expanded') === 'true') { 315 content.style.maxHeight = `${content.scrollHeight}px`; 316 } 317 } 318 } 319 else { 320 content.style.maxHeight = '0'; 321 } 322 }); 323 }); 324 }, 325 }; 326 327 Drupal.behaviors.vimeoTranscriptDownload = { 328 attach: function (context) { 329 const downloadButtons = context.querySelectorAll('.transcript-download'); 330 331 downloadButtons.forEach(button => { 332 if (button.dataset.downloadInitialized) { 333 return; 334 } 335 button.dataset.downloadInitialized = 'true'; 336 337 button.addEventListener('click', () => { 338 const wrapper = button.closest('.vimeo-video'); 339 if (!wrapper) { 340 return; 341 } 342 343 const container = wrapper.querySelector('.player-container[data-vi
343deo-id]'); 344 if (!container) { 345 return; 346 } 347 348 const instance = instances.get(container.dataset.videoId); 349 downloadTranscript(instance); 350 }); 351 }); 352 }, 353 }; 354 355})(Drupal);
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.