1document.addEventListener('DOMContentLoaded', function () { 2 3 const console = createDebugConsole(window.console); 4 5 6 const viewer = document.getElementById('kosher-stories-viewer'); 7 const content = document.querySelector('.kosher-stories-content'); 8 const storyBody = document.querySelector('.kosher-stories-body'); 9 const close = document.querySelector('.kosher-close'); 10 11 let currentCategoryIndex = 0; 12 const categories = Array.from(document.querySelectorAll('.kosher-story-thumb')); 13 14 let currentTermId = null; 15 let storyMuted = false; 16 const analyticsSessionId = getAnalyticsSessionId(); 17 let analyticsQueue = []; 18 let analyticsFlushTimer = null; 19 20 if (!viewer || !content || !storyBody) return; 21 22 function createDebugConsole(nativeConsole) { 23 let enabled = false; 24 25 try { 26 enabled = localStorage.getItem('kayco_stories_debug') === '1'; 27 } catch (error) { 28 enabled = false; 29 } 30 31 return { 32 log: function () { 33 if (enabled && nativeConsole && nativeConsole.log) { 34 nativeConsole.log.apply(nativeConsole, arguments); 35 } 36 }, 37 warn: function () { 38 if (enabled && nativeConsole && nativeConsole.warn) { 39 nativeConsole.warn.apply(nativeConsole, arguments); 40 } 41 }, 42 error: function () { 43 if (enabled && nativeConsole && nativeConsole.error) { 44 nativeConsole.error.apply(nativeConsole, arguments); 45 } 46 } 47 }; 48 } 49 50 function getAnalyticsSessionId() { 51 const key = 'kayco_story_session_id'; 52 let sessionId = sessionStorage.getItem(key); 53 54 if (!sessionId) { 55 sessionId = 'story_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2); 56 sessionStorage.setItem(key, sessionId); 57 } 58 59 return sessionId; 60 } 61 62 function queueStoryEvent(event) { 63 analyticsQueue.push({ 64 sessionId: analyticsSessionId, 65 timestamp: Date.now(), 66 ...event 67 }); 68 69 if (analyticsQueue.length >= 12) { 70 flushStoryEvents(); 71 return; 72 } 73 74 clearTimeout(analyticsFlushTimer); 75 analyticsFlushTimer = setTimeout(flushStoryEvents, 1200); 76 } 77 78 function flushStoryEvents(useBeacon = false) { 79 if (!analyticsQueue.length) { 80 return; 81 } 82 83 const events = analyticsQueue.splice(0, analyticsQueue.length); 84 const body = new URLSearchParams({ 85 action: 'kayco_track_story_events', 86 events: JSON.stringify(events) 87 }); 88 89 if (useBeacon && navigator.sendBeacon) { 90 navigator.sendBeacon(kosherStories.ajax_url, body); 91 return; 92 } 93 94 fetch(kosherStories.ajax_url, { 95 method: 'POST', 96 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 97 body 98 }).catch(() => { 99 analyticsQueue = events.concat(analyticsQueue).slice(0, 40); 100 }); 101 } 102 103 function queueDropoffFromState() { 104 const storyState = storyBody._kaycoStoryState; 105 106 if (!storyState || storyState.completed) { 107 return; 108 } 109 110 const postId = storyState.postIds[storyState.current]; 111 if (!postId) { 112 return; 113 } 114 115 queueStoryEvent({ 116 eventType: 'dropoff', 117 postId, 118 termId: storyState.termId, 119 slideIndex: storyState.current, 120 totalSlides: storyState.totalSlides, 121 duration: storyState.activeDuration || 0 122 }); 123 124 storyState.completed = true; 125 flushStoryEvents(true); 126 } 127 128 function getDefaultThumbSrc() { 129 return kosherStories.default_thumb_url || ''; 130 } 131 132 function getStoryThumbSrc(story) { 133 if (story && story.img) { 134 return story.img; 135 } 136 137 return getDefaultThumbSrc(); 138 } 139 140 function resetAllVideos() { 141 document.querySelectorAll('#kosher-stories video').forEach(video => { 142 video.pause(); 143 video.currentTime = 0; 144 }); 145} 146 147function stopAllStoryMedia() { 148
149 // ð´ STOP ALL VIDEOS (both normal + bg) 150 document.querySelectorAll('#kosher-stories-viewer video').forEach(video => { 151 video.onloadedmetadata = null; 152 video.onloadeddata = null; 153 video.pause(); 154 video.currentTime = 0; 155 156 video.querySelectorAll('source').forEach(source => { 157 source.removeAttribute('src'); 158 }); 159 160 video.removeAttribute('src'); 161 video.load(); 162 }); 163 164 // â±ï¸ CLEAR ALL TIMERS (VERY IMPORTANT) 165 document.querySelectorAll('.kosher-stories-content, .kosher-stories-body').forEach(wrapper => { 166 if (wrapper._timer) { 167 clearTimeout(wrapper._timer); 168 wrapper._timer = null; 169 } 170 }); 171 172} 173 174function playActiveVideo(activeSlide) { 175 176 const video = activeSlide.querySelector('video'); 177 178 if (!video) return; 179 180 video.currentTime = 0; 181 182 const playPromise = video.play(); 183 184 if (playPromise !== undefined) { 185 playPromise.catch(err => { 186 console.warn('Autoplay blocked:', err); 187 }); 188 } 189} 190 191 // ========================= 192 // ð TRACK VIEW 193 // ========================= 194 function trackView(postId, termId, duration) { 195 fetch(kosherStories.ajax_url, { 196 method: 'POST', 197 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 198 body: new URLSearchParams({ 199 action: 'kosher_track_story_view', 200 post_id: postId, 201 term_id: termId, 202 duration: duration 203 }) 204 }); 205 } 206 207 function getPollConfig() { 208 const currentConfig = window.KosherPollsPluginConfig || window.kosherPolls || window.kaycoPolls || {}; 209 210 return {
211 ajaxUrl: currentConfig.ajaxUrl || kosherStories.ajax_url, 212 nonce: currentConfig.nonce || kosherStories.poll_nonce || '', 213 messages: currentConfig.messages || {}, 214 action: window.KosherPollsPluginConfig ? 'kosher_submit_poll_vote' : 'kayco_submit_poll_vote' 215 }; 216 } 217 218 function formatPollNumber(value) { 219 return new Intl.NumberFormat().format(value || 0); 220 } 221 222 function createPollVoterMarkup(voter) { 223 return ` 224 <div class="kayco-poll-voter"> 225 ${voter.avatar ? `<img src="${voter.avatar}" alt="${voter.name}" loading="lazy" />` : ''} 226 <div class="kayco-poll-voter__copy"> 227 <strong>${voter.name}</strong> 228 ${voter.voted_at ? `<small>${voter.voted_at}</small>` : ''} 229 </div> 230 </div> 231 `; 232 } 233 234 function createPollAvatarMarkup(voter) { 235 return voter.avatar ? `<img src="${voter.avatar}" alt="${voter.name}" loading="lazy" />` : ''; 236 } 237 238 function initStoryPollCards(scope) { 239 if (!scope) { 240 return; 241 } 242 243 scope.querySelectorAll('.story-poll .kayco-poll-card').forEach((card) => { 244 if (card.dataset.storyPollBound === 'true') { 245 card.classList.add('is-visible'); 246 return; 247 } 248 249 card.dataset.storyPollBound = 'true'; 250 card.classList.add('is-visible'); 251 attachStoryPollHandlers(card); 252 syncStoryPollSelection(card); 253 }); 254 } 255 256 function storyPollCanVote(card) { 257 if (card.dataset.canVote) { 258 return card.dataset.canVote === 'true'; 259 } 260 261 return !!card.querySelector('.js-kayco-poll-submit') || !!card.querySelector('.js-kayco-poll-form input[type="radio"]:not([disabled])'); 262 } 263 264 function attachStoryPollHandlers(card) { 265 const form = card.querySelector('.js-kayco-poll-form'); 266 const toggle = card.querySelector('.js-kayco-poll-view-votes'); 267 let isSavingChange = false; 268 269 if (toggle) { 270 toggle.addEventListener('click', function (event) { 271 event.preventDefault(); 272 event.stopPropagation(); 273 274 const isOpen = toggle.classList.contains('is-open'); 275 276 card.querySelectorAll('.kayco-poll-option__voters').forEach((voters) => { 277 voters.toggleAttribute('hidden', isOpen); 278 }); 279 280 toggle.classList.toggle('is-open', !isOpen); 281 282 const label = toggle.querySelector('span'); 283 if (label) { 284 label.textContent = isOpen ? 'View votes' : 'Hide votes'; 285 } 286 }); 287 } 288 289 card.querySelectorAll('input[type="radio"]').forEach((input) => { 290 input.addEventListener('change', async function () { 291 syncStoryPollSelection(card); 292 293 if (card.dataset.hasVoted !== 'true' || isSavingChange || !form || !storyPollCanVote(card)) { 294 return; 295 } 296 297 const checked = form.querySelector('input[type="radio"]:checked'); 298 const message = card.querySelector('.js-kayco-poll-message'); 299 const previousVote = card.dataset.userVote || ''; 300 301 if (!checked || checked.value === previousVote) { 302 return; 303 } 304 305 isSavingChange = true; 306 307 if (message) { 308 message.textContent = getPollConfig().messages.loading || 'Saving your vote...'; 309 } 310 311 card.querySelectorAll('input[type="radio"]').forEach((field) => { 312 field.disabled = true; 313 }); 314 315 try { 316 const result = await submitStoryPollVote(card, checked.value); 317 318 if (result.success && result.data && result.data.payload) { 319 applyStoryPollPayload(card, result.data.payload, result.data.message || getPollConfig().messages.updated); 320 return; 321 } 322 323 if (result.data && result.data.payload) { 324 applyStoryPollPayload(card, result.data.payload, result.data.message || getPollConfig().messages.error); 325 } else if (message) { 326 message.textContent = (result.data && result.data.message) || getPollConfig().messages.error || 'Something went wrong while saving your vote.'; 327 } 328 } catch (error) { 329 if (message) { 330 message.textContent = getPollConfig().messages.error || 'Something went wrong while saving your vote.'; 331 } 332 } finally { 333 isSavingChange = false;
334 } 335 }); 336 }); 337 338 if (!form) { 339 return; 340 } 341 342 form.addEventListener('submit', async function (event) { 343 event.preventDefault(); 344 event.stopPropagation(); 345 346 const checked = form.querySelector('input[type="radio"]:checked'); 347 const submit = form.querySelector('.js-kayco-poll-submit'); 348 const message = card.querySelector('.js-kayco-poll-message'); 349 350 if (!checked || !submit || submit.disabled) { 351 return; 352 } 353 354 submit.disabled = true; 355 submit.classList.add('is-loading'); 356 357 if (message) { 358 message.textContent = getPollConfig().messages.loading || 'Saving your vote...'; 359 } 360 361 try { 362 const result = await submitStoryPollVote(card, checked.value); 363 364 if (result.success && result.data && result.data.payload) { 365 applyStoryPollPayload(card, result.data.payload, result.data.message); 366 return; 367 } 368 369 if (result.data && result.data.payload) { 370 applyStoryPollPayload(card, result.data.payload, result.data.message || getPollConfig().messages.error); 371 } else if (message) { 372 message.textContent = (result.data && result.data.message) || getPollConfig().messages.error || 'Something went wrong while saving your vote.'; 373 } 374 } catch (error) { 375 if (message) { 376 message.textContent = getPollConfig().messages.error || 'Something went wrong while saving your vote.'; 377 } 378 } finally { 379 submit.classList.remove('is-loading'); 380 syncStoryPollSelection(card); 381 } 382 }); 383 } 384 385 function syncStoryPollSelection(card) { 386 const checked = card.querySelector('input[type="radio"]:checked'); 387 const submit = card.querySelector('.js-kayco-poll-submit'); 388 389 card.querySelectorAll('.kayco-poll-option').forEach((option) => { 390 const isSelected = !!checked && option.dataset.optionId === checked.value; 391 option.classList.toggle('is-selected', isSelected); 392 option.dataset.isUserVote = isSelected ? 'true' : 'false'; 393 394 const status = option.querySelector('.js-kayco-poll-user-status'); 395 if (status) { 396 status.hidden = !isSelected || card.dataset.hasVoted !== 'true'; 397 } 398 }); 399 400 if (submit) { 401 const label = submit.querySelector('span'); 402 403 if (label) { 404 label.textContent = 'Submit Vote'; 405 } 406 407 submit.hidden = card.dataset.hasVoted === 'true'; 408 submit.disabled = card.dataset.hasVoted === 'true' || !checked || !storyPollCanVote(card); 409 } 410 } 411 412 function applyStoryPollPayload(card, payload, message) { 413 card.dataset.userVote = payload.userVote || ''; 414 card.dataset.hasVoted = payload.hasVoted ? 'true' : 'false'; 415 card.dataset.canVote = payload.canVote ? 'true' : 'false'; 416 card.classList.add('has-voted'); 417 418 const total = card.querySelector('.js-kayco-poll-total'); 419 if (total) { 420 total.textContent = formatPollNumber(payload.totalVotes || 0); 421 } 422 423 const messageNode = card.querySelector('.js-kayco-poll-message'); 424 if (messageNode) { 425 messageNode.textContent = message || getPollConfig().messages.updated || getPollConfig().messages.success || 'Your vote is saved.'; 426 } 427 428 const submit = card.querySelector('.js-kayco-poll-submit'); 429 if (submit) { 430 submit.classList.remove('is-loading'); 431 submit.hidden = !payload.canVote || payload.hasVoted; 432 } 433 434 const viewVotes = card.querySelector('.js-kayco-poll-view-votes'); 435 if (viewVotes) { 436 viewVotes.hidden = false; 437 viewVotes.classList.remove('is-open'); 438 const label = viewVotes.querySelector('span'); 439 if (label) { 440 label.textContent = 'View votes'; 441 } 442 } 443
444 (payload.options || []).forEach((optionData) => { 445 const option = card.querySelector(`.kayco-poll-option[data-option-id="${optionData.id}"]`); 446 447 if (!option) { 448 return; 449 } 450 451 const input = option.querySelector('input[type="radio"]'); 452 if (input) { 453 input.checked = !!optionData.isUserVote; 454 input.disabled = !payload.canVote; 455 } 456 457 option.classList.toggle('is-selected', !!optionData.isUserVote); 458 option.dataset.isUserVote = optionData.isUserVote ? 'true' : 'false'; 459 460 const status = option.querySelector('.js-kayco-poll-user-status'); 461 if (status) { 462 status.hidden = !optionData.isUserVote; 463 } 464 465 const percentNode = option.querySelector('.js-kayco-poll-percent'); 466 if (percentNode) { 467 percentNode.textContent = `${optionData.percent}%`; 468 } 469 470 const countLabel = option.querySelector('.js-kayco-poll-count-label'); 471 if (countLabel) { 472 countLabel.textContent = optionData.countLabel || `${formatPollNumber(optionData.count || 0)} votes`; 473 } 474 475 const bar = option.querySelector('.js-kayco-poll-bar'); 476 if (bar) { 477 bar.style.width = `${optionData.percent}%`; 478 } 479 480 const avatars = option.querySelector('.kayco-poll-option__avatars'); 481 if (avatars) { 482 avatars.innerHTML = (optionData.avatarStack || []).map(createPollAvatarMarkup).join(''); 483 } 484 485 const voters = option.querySelector('.kayco-poll-option__voters'); 486 if (voters) { 487 voters.innerHTML = (optionData.voters || []).map(createPollVoterMarkup).join(''); 488 voters.setAttribute('hidden', ''); 489 } 490 }); 491 492 syncStoryPollSelection(card); 493 } 494 495 function submitStoryPollVote(card, optionValue) { 496 const config = getPollConfig(); 497 const body = new URLSearchParams({ 498 action: config.action, 499 nonce: config.nonce, 500 poll_id: card.dataset.pollId || '', 501 poll_option: optionValue 502 }); 503 504 return fetch(config.ajaxUrl, { 505 method: 'POST', 506 credentials: 'same-origin', 507 headers: { 508 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' 509 }, 510 body: body.toString() 511 }).then((response) => response.json()); 512 } 513 514 window.addEventListener('beforeunload', function () { 515 queueDropoffFromState(); 516 flushStoryEvents(true); 517 }); 518 519function updateThumbFromServer(termId) { 520 521 fetch(kosherStories.ajax_url, { 522 method: 'POST', 523 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 524 body: new URLSearchParams({ 525 action: 'kosher_get_story_thumb', 526 term_id: termId, 527 t: Date.now() 528 }) 529 }) 530 .then(res => res.json()) 531 .then(data => { 532 533 if (!data.success) return; 534 535 const stories = Array.isArray(data.data) ? data.data : []; 536 537 if (!stories.length) { 538 return; 539 } 540 541 const seenData = getSeenData(); 542 const seenPosts = (seenData[termId]?.posts || []).map(String); 543 const seenSet = new Set(seenPosts); 544 545 let target = null; 546 547 // ð¥ FIRST unseen 548 for (let story of stories) { 549 if (!seenSet.has(String(story.id))) { 550 target = story; 551 break; 552 } 553 } 554 555 // fallback â first story 556 if (!target) target = stories[0]; 557 558 const thumb = document.querySelector(`.kosher-story-thumb[data-term="${termId}"]`); 559 if (!thumb) return; 560 561 const inner = thumb.querySelector('.thumb-inner'); 562 if (!inner) return; 563 564 const thumbSrc = getStoryThumbSrc(target); 565 if (!thumbSrc) return; 566 567 const img = new Image(); 568 img.src = thumbSrc; 569 570 img.onload = () => { 571 inner.innerHTML = ''; 572 inner.appendChild(img); 573 }; 574 575 }); 576} 577 578 function preloadAllThumbs() { 579
580 categories.forEach(el => { 581 582 const termId = el.getAttribute('data-term'); 583 584 fetch(kosherStories.ajax_url, { 585 method: 'POST', 586 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 587 body: new URLSearchParams({ 588 action: 'kosher_get_story_thumb', 589 term_id: termId 590 }) 591 }) 592 .then(res => res.json()) 593 .then(data => { 594 595 if (!data.success) return; 596 597 const stories = Array.isArray(data.data) ? data.data : []; 598 const target = stories.find(story => story && story.img) || stories[0]; 599 const thumbSrc = getStoryThumbSrc(target); 600 601 if (!target || !thumbSrc) return; 602 603 const inner = el.querySelector('.thumb-inner'); 604 if (!inner) return; 605 606 inner.innerHTML = `<img src="${thumbSrc}" alt="">`; 607 }); 608 609 }); 610 611} 612 613 // ========================= 614 // ðï¸ STORAGE 615 // ========================= 616 function getSeenData() { 617 return JSON.parse(localStorage.getItem('kosher_story_seen') || '{}'); 618 } 619 620 function saveSeenData(data) { 621 localStorage.setItem('kosher_story_seen', JSON.stringify(data)); 622 } 623 624 function markStorySeen(termId, postId) { 625 const data = getSeenData(); 626 627 if (!data[termId]) { 628 data[termId] = { posts: [] }; 629 } 630 631 if (!data[termId].posts.includes(String(postId))) { 632 data[termId].posts.push(String(postId)); 633 } 634 635 saveSeenData(data); 636 } 637 638 function markCategorySeen(termId, postIds) { 639 const data = getSeenData(); 640 data[termId] = { posts: postIds.map(id => String(id)) }; 641 saveSeenData(data); 642 updateThumbUI(termId, true); 643 } 644 645 function updateThumbUI(termId, seen) { 646 const el = document.querySelector(`.kosher-story-thumb[data-term="${termId}"]`); 647 if (!el) return; 648 seen ? el.classList.add('seen') : el.classList.remove('seen'); 649 } 650 651 function checkSeenState(termId, postIds) { 652 653 const data = getSeenData(); 654 655 if (!data[termId]) { 656 updateThumbUI(termId, false); 657 return; 658 } 659 660 const seenPosts = (data[termId].posts || []).map(String); 661 const incoming = postIds.map(id => String(id)); 662 663 const seenSet = new Set(seenPosts); 664 665 const hasUnseen = incoming.some(id => !seenSet.has(id)); 666 667 if (hasUnseen) { 668 updateThumbUI(termId, false); 669 } else { 670 updateThumbUI(termId, true); 671 } 672 } 673 674 function initAllThumbsSeenState() { 675 const data = getSeenData(); 676 677 categories.forEach(el => { 678 const termId = el.getAttribute('data-term'); 679 if (!data[termId]) return; 680 updateThumbUI(termId, true); 681 }); 682 } 683 684 initAllThumbsSeenState(); 685 preloadAllThumbs(); 686 refreshStoriesState(); 687 688 // ========================= 689 // ð¥ START INDEX LOGIC 690 // ========================= 691 function getStartIndex(postIds, termId) { 692 693 const data = getSeenData(); 694 695 if (!data[termId]) return 0; 696 697 const seenPosts = (data[termId].posts || []).map(String); 698 const seenSet = new Set(seenPosts); 699 700 // ð¥ FIRST unseen 701 for (let i = 0; i < postIds.length; i++) { 702 if (!seenSet.has(String(postIds[i]))) { 703 return i; // ð THIS WILL BE 10 704 } 705 } 706 707 return 0; 708 } 709 710 // ========================= 711 // ð¥ LIVE REFRESH 712 // ========================= 713function refreshStoriesState() { 714 715 // ð« DO NOT OVERRIDE while viewer is open 716 if (!viewer.classList.contains('hidden')) { 717 return; 718 } 719 720 categories.forEach(el => { 721 722 const termId = el.getAttribute('data-term'); 723 724 fetch(kosherStories.ajax_url, { 725 method: 'POST', 726 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 727 body: new URLSearchParams({ 728 action: 'kosher_get_story_ids', 729 term_id: termId 730 }) 731 }) 732 .then(res => res.json()) 733 .then(data => { 734 735 if (!data.success) return; 736 737 const postIds = data.data.post_ids; 738 739 const dataSeen = getSeenData(); 740 const seenPosts = (dataSeen[termId]?.posts || []).map(String); 741 742 const seenSet = new Set(seenPosts); 743 744 const hasUnseen = postIds.some(id => !seenSet.has(String(id))); 745 746 updateThumbUI(termId, !hasUnseen); 747 748 // â ONLY update server thumb when viewer CLOSED 749 updateThumbFromServer(termId); 750 751 }); 752 753 }); 754} 755 756 setInterval(refreshStoriesState, 4000); 757 758 // ========================= 759 // ð¼ï¸ THUMB UPDATE (NEW) 760 // ========================= 761function updateThumbFromSlides(termId) { 762 763 764 // â³ Ensure DOM is ready (important after AJAX render) 765 requestAnimationFrame(() => { 766 767 const wrapper = document.querySelector('.kosher-stories-body'); 768 769 if (!wrapper) { 770 console.error('â NO .kosher-stories-content FOUND'); 771 return; 772 } 773 774 const firstSlide = wrapper.querySelector('.slide'); 775 776 if (!firstSlide) { 777 console.error('â NO .slide FOUND'); 778 return; 779 } 780 781 782 let imgSrc = null; 783 784 // ========================= 785 // ð¼ï¸ 1. DIRECT IMAGE 786 // ========================= 787 const img = firstSlide.querySelector('img'); 788 789 if (img && img.src) { 790 imgSrc = img.src; 791 } 792 793 // ========================= 794 // ð¬ 2. VIDEO POSTER 795 // ========================= 796 if (!imgSrc) { 797 798 const video = firstSlide.querySelector('video'); 799 800 if (video) { 801 802 const poster = video.getAttribute('poster'); 803 804 if (poster) { 805 imgSrc = poster; 806 } else { 807 console.warn('â ï¸ Video has NO poster'); 808 } 809 } 810 } 811 812 // ========================= 813 // ð¼ï¸ 3. BACKGROUND IMAGE (YOUR CASE) 814 // ========================= 815 if (!imgSrc) { 816 817 const bgImg = firstSlide.querySelector('.story-background--image img'); 818 819 if (bgImg && bgImg.src) { 820 imgSrc = bgImg.src; 821 } 822 } 823 824 // ========================= 825 // ð¬ 4. VIDEO â TRY FRAME (OPTIONAL SAFE FALLBACK) 826 // ========================= 827 if (!imgSrc) { 828 829 const video = firstSlide.querySelector('video'); 830 831 if (video && video.src) { 832 833 console.warn('â ï¸ No poster â fallback to video frame'); 834 835 try { 836 const canvas = document.createElement('canvas'); 837 const ctx = canvas.getContext('2d'); 838 839 canvas.width = 300; 840 canvas.height = 200; 841 842 video.currentTime = 0.1; 843 844 video.addEventListener('loadeddata', function captureFrame() { 845 846 ctx.drawImage(video, 0, 0, canvas.width, canvas.height); 847 848 imgSrc = canvas.toDataURL('image/jpeg'); 849 850 applyThumb(imgSrc); 851 852 video.removeEventListener('loadeddata', captureFrame); 853 854 }); 855 856 return; // stop normal flow (async) 857 } catch (e) { 858 console.error('â Failed to generate video frame', e); 859 } 860 } 861 } 862 863 // ========================= 864 // â FINAL FALLBACK 865 // ========================= 866 if (!imgSrc) { 867 console.warn('â No thumbnail source found â using default icon'); 868 imgSrc = getDefaultThumbSrc(); 869 } 870 871 if (imgSrc) { 872 applyThumb(imgSrc); 873 } 874 875 // ========================= 876 // ð¯ APPLY THUMB 877 // ========================= 878 function applyThumb(src) { 879 880 881 const wrappers = document.querySelectorAll('.kosher-story-thumb'); 882 883 884 let found = false;
885 886 wrappers.forEach(wrapper => { 887 888 const dataTerm = wrapper.getAttribute('data-term'); 889 890 891 if (String(dataTerm) === String(termId)) { 892 893 const inner = wrapper.querySelector('.thumb-inner'); 894 895 if (!inner) { 896 console.error('â .thumb-inner missing inside:', wrapper); 897 return; 898 } 899 900 inner.innerHTML = `<img src="${src}" alt="">`; 901 902 903 found = true; 904 } 905 }); 906 907 if (!found) { 908 console.error('â NO MATCHING THUMB FOUND FOR termId:', termId); 909 } 910 } 911 912 }); 913} 914 915 function parsePostIds(value) { 916 try { 917 const ids = JSON.parse(value || '[]'); 918 return Array.isArray(ids) ? ids : []; 919 } catch (error) { 920 return []; 921 } 922 } 923 924 function stopStoryWrapper(wrapper, resetSlides = false) { 925 if (!wrapper) { 926 return; 927 } 928 929 if (wrapper._timer) { 930 clearTimeout(wrapper._timer); 931 wrapper._timer = null; 932 } 933 934 // Invalidate delayed play retries created by the story engine. Without 935 // this, a retry can restart a video after its carousel card lost focus. 936 wrapper._kaycoPlaybackGeneration = (wrapper._kaycoPlaybackGeneration || 0) + 1; 937 938 wrapper.querySelectorAll('video').forEach(video => { 939 video.onloadedmetadata = null; 940 video.onloadeddata = null; 941 942 video.pause(); 943 video.currentTime = 0; 944 }); 945 946 if (!resetSlides) { 947 return; 948 } 949 950 wrapper.querySelectorAll('.slide').forEach((slide, index) => { 951 slide.style.display = index === 0 ? 'block' : 'none'; 952 }); 953 954 wrapper.querySelectorAll('.bar').forEach((bar) => { 955 const span = bar.querySelector('span'); 956 bar.classList.remove('seen'); 957 958 if (span) {
959 span.style.animation = 'none'; 960 span.style.transform = 'scaleX(0)'; 961 } 962 }); 963 } 964 965 function loadStoriesCarousel(startIndex = 0) { 966 const startThumb = categories[startIndex]; 967 const startTermId = startThumb ? startThumb.getAttribute('data-term') : ''; 968 969 fetch(kosherStories.ajax_url, { 970 method: 'POST', 971 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 972 body: new URLSearchParams({ 973 action: 'kosher_get_all_stories_carousel', 974 start_term_id: startTermId 975 }) 976 }) 977 .then(res => res.json()) 978 .then(data => { 979 if (!data.success) { 980 loadCategory(startIndex); 981 return; 982 } 983 984 storyBody.innerHTML = data.data.html; 985 viewer.classList.add('is-reels-carousel'); 986 viewer.classList.remove('hidden'); 987 initReelsCarousel(storyBody, data.data.start_index || 0); 988 }) 989 .catch(() => loadCategory(startIndex)); 990 } 991 992 function initReelsCarousel(scope, initialIndex = 0, options = {}) { 993 const track = scope.querySelector('.kosher-reels__track'); 994 const shell = scope.querySelector('.kosher-reels') || scope; 995 const originalCards = Array.from(scope.querySelectorAll('.kosher-reel-card:not([data-reel-clone])')); 996 const prev = scope.querySelector('.kosher-reels__nav--prev'); 997 const next = scope.querySelector('.kosher-reels__nav--next'); 998 999 if (!track || !originalCards.length) { 1000 return; 1001 } 1002 1003 if (originalCards.length > 1 && track.dataset.reelsLoopReady !== 'true') { 1004 originalCards.forEach((card) => { 1005 const clone = card.cloneNode(true); 1006 clone.dataset.reelClone = 'after'; 1007 track.appendChild(clone); 1008 }); 1009 1010 originalCards.slice().reverse().forEach((card) => { 1011 const clone = card.cloneNode(true); 1012 clone.dataset.reelClone = 'before'; 1013 track.insertBefore(clone, track.firstChild); 1014 }); 1015 1016 track.dataset.reelsLoopReady = 'true'; 1017 } 1018 1019 const cards = Array.from(scope.querySelectorAll('.kosher-reel-card')); 1020 const originalCount = originalCards.length; 1021 const middleOffset = originalCount > 1 ? originalCount : 0; 1022 1023 let activeIndex = Math.max(0, Math.min(originalCount - 1, initialIndex)); 1024 let activeVisualIndex = middleOffset + activeIndex; 1025 let activeStarted = false; 1026 let wheelLocked = false; 1027 let touchStartX = null; 1028 let touchStartY = null; 1029 let touchLastX = null; 1030 let touchDragging = false; 1031 let touchBaseTranslateX = 0; 1032 let trackTranslateX = 0; 1033 1034 function syncButtons() { 1035 if (prev) { 1036 prev.disabled = originalCount <= 1; 1037 } 1038 1039 if (next) { 1040 next.disabled = originalCount <= 1; 1041 } 1042 } 1043 1044 function normalizeIndex(index) { 1045 if (!originalCount) { 1046 return 0; 1047 } 1048 1049 return (index % originalCount + originalCount) % originalCount; 1050 } 1051 1052 function getCenteredTrackTranslate() { 1053 const activeCard = cards[activeVisualIndex]; 1054 const viewport = scope.querySelector('.kosher-reels__viewport') || scope; 1055 if (!activeCard || !viewport) { 1056 return trackTranslateX; 1057 } 1058 1059 const viewportWidth = viewport.clientWidth || viewport.getBoundingClientRect().width; 1060 const cardWidth = activeCard.offsetWidth || activeCard.getBoundingClientRect().width; 1061 const cardLeft = activeCard.offsetLeft; 1062 1063 return (viewportWidth / 2) - (cardLeft + (cardWidth / 2)); 1064 } 1065 1066 function centerActiveCard(animate = true) { 1067 trackTranslateX = getCenteredTrackTranslate(); 1068 1069 track.style.transition = animate ? '' : 'none'; 1070 track.style.transform = `translate3d(${trackTranslateX}px, 0, 0)`; 1071 1072 if (!animate) { 1073 void track.offsetWidth; 1074 track.style.transition = ''; 1075 } 1076 } 1077 1078 function activateCard(index, initialSlideIndex = null, shouldStart = false, allowMutedFallback = false) { 1079 activeIndex = normalizeIndex(index); 1080 activeVisualIndex = middleOffset + activeIndex; 1081 activeStarted = shouldStart; 1082 shell.classList.toggle('is-playing', activeStarted); 1083 1084 cards.forEach((card, cardIndex) => { 1085 const dailyStories = card.querySelector('.daily-stories'); 1086 const isActive = cardIndex === activeVisualIndex; 1087 1088 card.classList.toggle('is-active', isActive); 1089 card.setAttribute('aria-hidden', isActive ? 'false' : 'true'); 1090 stopStoryWrapper(dailyStories, true); 1091 }); 1092 1093 centerActiveCard(); 1094 syncButtons(); 1095 1096 if (!shouldStart) { 1097 return; 1098 } 1099 1100 startActiveCard(initialSlideIndex, allowMutedFallback); 1101 } 1102 1103 function startActiveCard(initialSlideIndex = null, allowMutedFallback = false) { 1104 const activeCard = cards[activeVisualIndex]; 1105 const dailyStories = activeCard.querySelector('.daily-stories'); 1106 const termId = activeCard.getAttribute('data-term'); 1107 const postIds = parsePostIds(activeCard.getAttribute('data-post-ids')); 1108 1109 1110 currentCategoryIndex = categories.findIndex(category => category.getAttribute('data-term') === termId); 1111 currentTermId = termId; 1112 1113 if (postIds.length) { 1114 checkSeenState(termId, postIds); 1115 } 1116 1117 initStoryEngine(dailyStories, postIds, termId, initialSlideIndex, { 1118 carousel: true, 1119 allowMutedFallback, 1120 onComplete: function () { 1121 markCategorySeen(termId, postIds); 1122 // moveCarousel(1, null, true); 1123 }, 1124 onPreviousCategory: function () { 1125 moveCarousel(-1, 'last', true); 1126 } 1127 }); 1128 1129 activeStarted = true; 1130 shell.classList.add('is-playing'); 1131 } 1132 1133 function moveCarousel(direction, initialSlideIndex = null, allowMutedFallback = false) { 1134 1135 1136 if (originalCount <= 1) { 1137 startActiveCard(null, allowMutedFallback); 1138 return; 1139 } 1140 1141 activeVisualIndex += direction; 1142 activeIndex = normalizeIndex(activeVisualIndex - middleOffset); 1143 activeStarted = true; 1144 shell.classList.add('is-playing'); 1145 1146 cards.forEach((card, cardIndex) => { 1147 const dailyStories = card.querySelector('.daily-stories'); 1148 const isActive = cardIndex === activeVisualIndex; 1149 1150 card.classList.toggle('is-active', isActive); 1151 card.setAttribute('aria-hidden', isActive ? 'false' : 'true'); 1152 stopStoryWrapper(dailyStories, true); 1153 }); 1154 1155 centerActiveCard(); 1156 syncButtons(); 1157 1158 const needsSnap = activeVisualIndex < middleOffset || activeVisualIndex >= middleOffset + originalCount; 1159 1160 if (!needsSnap) { 1161 startActiveCard(initialSlideIndex, allowMutedFallback); 1162 return; 1163 } 1164 1165 startActiveCard(initialSlideIndex, allowMutedFallback); 1166 1167 setTimeout(() => { 1168 activeVisualIndex = middleOffset + activeIndex; 1169 1170 cards.forEach((card, cardIndex) => { 1171 const dailyStories = card.querySelector('.daily-stories'); 1172 const isActive = cardIndex === activeVisualIndex; 1173 1174 card.classList.toggle('is-active', isActive); 1175 card.setAttribute('aria-hidden', isActive ? 'false' : 'true'); 1176 stopStoryWrapper(dailyStories, true); 1177 }); 1178 1179 centerActiveCard(false); 1180 startActiveCard(initialSlideIndex, allowMutedFallback); 1181 }, 390); 1182 1183 } 1184 1185 cards.forEach((card, index) => { 1186 card.addEventListener('click', function (event) { 1187 if ( 1188 event.target.closest('.prev-slide') || 1189 event.target.closest('.next-slide') || 1190 event.target.closest('.story-poll') || 1191 event.target.closest('.overlay a') 1192 ) { 1193 return; 1194 } 1195 1196 if (event._kaycoStoryHandled) { 1197 return; 1198 } 1199 1200 if (index === activeVisualIndex) { 1201 if (!activeStarted) { 1202 startActiveCard(); 1203 return; 1204 } 1205 1206 const dailyStories = card.querySelector('.daily-stories'); 1207 if ( 1208 dailyStories &&
1209 dailyStories._kaycoStoryControls && 1210 dailyStories._kaycoStoryControls.hasActiveVideo() 1211 ) { 1212 dailyStories._kaycoStoryControls.toggleVideoPlayback(); 1213 } 1214 return; 1215 } 1216 1217 activateCard(normalizeIndex(index - middleOffset), null, true); 1218 }); 1219 }); 1220 1221 if (prev) { 1222 prev.addEventListener('click', function () { 1223 1224 moveCarousel(-1, 'last'); 1225 }); 1226 } 1227 1228 if (next) { 1229 next.addEventListener('click', function () { 1230 1231 moveCarousel(1); 1232 }); 1233 } 1234 1235 scope.addEventListener('wheel', function (event) { 1236 const horizontalIntent = Math.abs(event.deltaX) > Math.abs(event.deltaY) && Math.abs(event.deltaX) >= 24; 1237 1238 if (wheelLocked || !horizontalIntent) { 1239 return; 1240 } 1241 1242 event.preventDefault(); 1243 wheelLocked = true; 1244 moveCarousel(event.deltaX > 0 ? 1 : -1, null, true); 1245 setTimeout(() => { 1246 wheelLocked = false; 1247 }, 420); 1248 }, { passive: false }); 1249 1250 scope.addEventListener('touchstart', function (event) { 1251 if (event.touches.length !== 1) { 1252 touchStartX = null; 1253 touchStartY = null; 1254 touchLastX = null; 1255 touchDragging = false; 1256 return; 1257 } 1258 1259 touchStartX = event.touches[0].clientX; 1260 touchStartY = event.touches[0].clientY; 1261 touchLastX = touchStartX; 1262 touchBaseTranslateX = trackTranslateX; 1263 touchDragging = false; 1264 }, { passive: true }); 1265 1266 scope.addEventListener('touchmove', function (event) { 1267 if (touchStartX === null || touchStartY === null || event.touches.length !== 1) { 1268 return; 1269 } 1270 1271 const touch = event.touches[0]; 1272 const deltaX = touch.clientX - touchStartX; 1273 const deltaY = touch.clientY - touchStartY; 1274 1275 if (!touchDragging && Math.abs(deltaX) <= 8) { 1276 return; 1277 } 1278 1279 if (!touchDragging && Math.abs(deltaX) <= Math.abs(deltaY)) { 1280 return; 1281 } 1282 1283 event.preventDefault(); 1284 touchDragging = true; 1285 touchLastX = touch.clientX; 1286 track.style.transition = 'none'; 1287 trackTranslateX = touchBaseTranslateX + deltaX; 1288 track.style.transform = `translate3d(${trackTranslateX}px, 0, 0)`; 1289 }, { passive: false }); 1290 1291 scope.addEventListener('touchend', function (event) { 1292 if (touchStartX === null || touchStartY === null) { 1293 return; 1294 } 1295 1296 const touch = event.changedTouches[0]; 1297 const deltaX = (touchLastX !== null ? touchLastX : touch.clientX) - touchStartX; 1298 const deltaY = touch.clientY - touchStartY; 1299 1300 touchStartX = null; 1301 touchStartY = null; 1302 touchLastX = null; 1303 1304 if (!touchDragging || Math.abs(deltaX) < 36 || Math.abs(deltaX) <= Math.abs(deltaY)) { 1305 touchDragging = false; 1306 centerActiveCard(true); 1307 return; 1308 } 1309 1310 touchDragging = false; 1311 moveCarousel(deltaX < 0 ? 1 : -1, null, true); 1312 }, { passive: true }); 1313 1314 if (scope._reelsResizeHandler) { 1315 window.removeEventListener('resize', scope._reelsResizeHandler); 1316 } 1317 1318 scope._reelsResizeHandler = centerActiveCard; 1319 window.addEventListener('resize', scope._reelsResizeHandler, { passive: true }); 1320 activateCard(activeIndex, null, options.autoplay === true); 1321 1322 requestAnimationFrame(() => { 1323 centerActiveCard(false); 1324 }); 1325 } 1326 1327 // ========================= 1328 // LOAD CATEGORY 1329 // ========================= 1330 function loadCategory(index, options = {}) { 1331 1332 if (!categories[index]) { 1333 viewer.classList.add('hidden');
1334 storyBody.innerHTML = ''; 1335 return; 1336 } 1337 1338 currentCategoryIndex = index; 1339 const termId = categories[index].getAttribute('data-term'); 1340 currentTermId = termId; 1341 viewer.classList.remove('is-reels-carousel'); 1342 1343 fetch(kosherStories.ajax_url, { 1344 method: 'POST', 1345 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 1346 body: new URLSearchParams({ 1347 action: 'kosher_get_category_stories', 1348 term_id: termId 1349 }) 1350 }) 1351 .then(res => res.json()) 1352.then(data => { 1353 1354 if (!data.success) { 1355 loadCategory(index + 1); 1356 return; 1357 } 1358 1359 storyBody.innerHTML = data.data.html; 1360 viewer.classList.remove('hidden'); 1361 1362 // ð¥ FIXED TIMING 1363setTimeout(() => { 1364 updateThumbFromSlides(termId); 1365}, 50); 1366 1367 initStoryEngine(storyBody, data.data.post_ids, termId, options.initialSlideIndex); 1368 1369 if (data.data.post_ids) { 1370 checkSeenState(termId, data.data.post_ids); 1371 } 1372}) 1373 .catch(() => loadCategory(index + 1)); 1374 } 1375 1376 categories.forEach((el, i) => { 1377 el.addEventListener('click', () => loadStoriesCarousel(i)); 1378 }); 1379 1380 document.querySelectorAll('[data-kosher-stories-inline]').forEach((inlineCarousel) => { 1381 initReelsCarousel(inlineCarousel, 0, { autoplay: false }); 1382 }); 1383 1384 function closeViewer() { 1385 queueDropoffFromState(); 1386 stopAllStoryMedia(); 1387 if (storyBody._reelsResizeHandler) { 1388 window.removeEventListener('resize', storyBody._reelsResizeHandler); 1389 storyBody._reelsResizeHandler = null; 1390 } 1391 viewer.classList.add('hidden'); 1392 viewer.classList.remove('is-reels-carousel'); 1393 storyBody.innerHTML = ''; 1394 refreshStoriesState(); 1395 } 1396 1397 if (close) { 1398 close.addEventListener('click', closeViewer); 1399 } 1400 1401 document.addEventListener('keydown', e => { 1402 if (e.key === 'Escape' && !viewer.classList.contains('hidden')) { 1403 closeViewer(); 1404 } 1405 }); 1406 1407 // ========================= 1408 // ð¬ STORY ENGINE 1409 // ========================= 1410 window.initStoryEngine = function(wrapper, postIds, termId, initialSlideIndex = null, options = {}) { 1411 1412 if (wrapper._timer) { 1413 clearTimeout(wrapper._timer); 1414 wrapper._timer = null; 1415 } 1416 1417 const slides = wrapper.querySelectorAll('.slide'); 1418 const bars = wrapper.querySelectorAll('.bar'); 1419 const progressBars = wrapper.querySelector('.progress-bars'); 1420 const prevArrow = wrapper.querySelector('.prev-slide'); 1421 const nextArrow = wrapper.querySelector('.next-slide'); 1422 const muteButton = wrapper.querySelector('.kosher-story-sound'); 1423 1424 if (!slides.length) return; 1425 1426 let current = resolveInitialSlideIndex(postIds, termId, initialSlideIndex, slides.length); 1427 let isTransitioning = false; 1428 let isPaused = false; 1429 let remainingTime = 0; 1430 let activeDuration = 0; 1431 let slideStartedAt = 0; 1432 let preventClickAfterTouch = false; 1433 let touchStartX = null; 1434 let touchStartY = null; 1435 let touchStartTarget = null; 1436 const playbackGeneration = (wrapper._kaycoPlaybackGeneration || 0) + 1; 1437 wrapper._kaycoPlaybackGeneration = playbackGeneration; 1438 wrapper._kaycoStoryState = { 1439 termId, 1440 postIds, 1441 current, 1442 totalSlides: slides.length, 1443 activeDuration: 0, 1444 completed: false 1445 }; 1446 storyBody._kaycoStoryState = wrapper._kaycoStoryState; 1447 1448function moveTo(index) { 1449 1450 slides.forEach((slide, i) => { 1451 1452 const video = slide.querySelector('video'); 1453 const bgVideo = slide.querySelector('.story-bg-video'); 1454 1455 // ð¯ HIDE/SHOW 1456 slide.style.display = i === index ? 'block' : 'none'; 1457 1458 // Stop inactive slides only. The active slide is controlled by the play path. 1459 if (video && i !== index) { 1460 1461 video.pause(); 1462 video.currentTime = 0; 1463 } 1464 1465 if (bgVideo && i !== index) { 1466 1467 bgVideo.pause(); 1468 bgVideo.currentTime = 0; 1469 } 1470 1471 // Playback starts after the focused slide is visible. 1472 }); 1473} 1474 1475 function clearStoryTimer() { 1476 if (wrapper._timer) { 1477 clearTimeout(wrapper._timer); 1478 wrapper._timer = null; 1479 } 1480 } 1481 1482 function getActiveSlide() { 1483 return slides[current] || null; 1484 } 1485 1486 function activeSlideHasPoll() { 1487 const slide = getActiveSlide(); 1488 return !!(slide && (slide.dataset.hasPoll === 'true' || slide.querySelector('.story-poll'))); 1489 } 1490 1491 function getActiveProgressBar() { 1492 const bar = bars[current]; 1493 return bar ? bar.querySelector('span') : null; 1494 } 1495 1496 function getActiveVideos() { 1497 const slide = getActiveSlide(); 1498 return slide ? Array.from(slide.querySelectorAll('video')) : []; 1499 } 1500 1501 function syncMuteButton() { 1502 if (!muteButton) { 1503 return; 1504 } 1505 1506 const activeSlide = getActiveSlide(); 1507 const hasSound = !!activeSlide && activeSlide.dataset.hasSound === 'true'; 1508 1509 muteButton.hidden = !hasSound; 1510 muteButton.setAttribute('aria-pressed', storyMuted ? 'true' : 'false'); 1511 muteButton.setAttribute('aria-label', storyMuted ? 'Unmute story' : 'Mute story'); 1512 muteButton.title = storyMuted ? 'Unmute story' : 'Mute story'; 1513 } 1514 1515 function applyMuteState() {
1516 getActiveVideos().forEach(video => { 1517 video.muted = storyMuted; 1518 video.defaultMuted = storyMuted; 1519 }); 1520 syncMuteButton(); 1521 } 1522 1523 function keepCarouselVideoPlaying(video) { 1524 if (!options.carousel || !video) { 1525 return; 1526 } 1527 1528 const expectedPostId = postIds[current]; 1529 1530 [250, 800, 1500].forEach((delay) => { 1531 setTimeout(() => { 1532 const stillActive = wrapper._kaycoStoryState && 1533 wrapper._kaycoPlaybackGeneration === playbackGeneration && 1534 (!options.carousel || !!wrapper.closest('.kosher-reel-card.is-active')) && 1535 wrapper._kaycoStoryState.current === current && 1536 postIds[current] === expectedPostId; 1537 1538 if (!stillActive || video.ended || !video.paused) { 1539 return; 1540 } 1541 1542 1543 video.play().catch((error) => { 1544 1545 }); 1546 }, delay); 1547 }); 1548 } 1549 1550 function playStoryVideo(video) { 1551 if (!video) { 1552 1553 return Promise.reject(new Error('No video element')); 1554 } 1555 1556 if (!video.paused && !video.ended) { 1557 1558 return Promise.resolve(true); 1559 } 1560 1561 if (options.allowMutedFallback) { 1562 video.muted = storyMuted; 1563 video.defaultMuted = storyMuted; 1564 video.toggleAttribute('muted', storyMuted); 1565 } 1566 1567 video.playsInline = true; 1568 video.setAttribute('playsinline', ''); 1569 video.setAttribute('webkit-playsinline', ''); 1570 1571 1572 const playPromise = video.play(); 1573 1574 if (playPromise !== undefined) { 1575 return playPromise.then(() => { 1576 1577 setTimeout(() => { 1578 1579 }, 800); 1580 keepCarouselVideoPlaying(video); 1581 return true; 1582 }).catch((error) => { 1583 1584 if (options.allowMutedFallback && !storyMuted) { 1585 storyMuted = true; 1586 applyMuteState(); 1587 video.play().then(() => { 1588 1589 }).catch((retryError) => { 1590 1591 clearStoryTimer(); 1592 isPaused = true; 1593 setProgressAnimationState('paused'); 1594 throw retryError; 1595 }); 1596 } 1597 1598 clearStoryTimer(); 1599 isPaused = true; 1600 setProgressAnimationState('paused'); 1601 throw error; 1602 }); 1603 } 1604 1605 return Promise.resolve(true); 1606 } 1607 1608 function setProgressAnimationState(state) { 1609 const progressBar = getActiveProgressBar(); 1610 1611 if (progressBar) { 1612 progressBar.style.animationPlayState = state; 1613 } 1614 } 1615 1616 function setPollSlideMode(enabled) { 1617 wrapper.classList.toggle('is-poll-slide', enabled); 1618 1619 if (progressBars) { 1620 progressBars.toggleAttribute('hidden', enabled); 1621 } 1622 } 1623 1624 function pauseActiveMedia() { 1625 getActiveVideos().forEach(video => { 1626 1627 video.pause(); 1628 }); 1629 } 1630 1631 function resumeActiveMedia() { 1632 getActiveVideos().forEach(video => { 1633 playStoryVideo(video); 1634 }); 1635 } 1636 1637 function startStoryTimer(duration) { 1638 activeDuration = duration; 1639 remainingTime = duration; 1640 slideStartedAt = Date.now(); 1641 1642 clearStoryTimer(); 1643 1644 wrapper._timer = setTimeout(() => { 1645 wrapper._timer = null; 1646 playSlide(current + 1); 1647 }, duration); 1648 } 1649 1650 function pauseStoryPlayback() { 1651 if (activeSlideHasPoll()) { 1652 pauseActiveMedia(); 1653 return; 1654 } 1655 1656 if (isPaused) { 1657 return; 1658 } 1659 1660 const slide = getActiveSlide(); 1661 if (!slide) { 1662 return; 1663 } 1664 1665 isPaused = true; 1666 1667 if (activeDuration > 0) {
1668 const elapsed = Date.now() - slideStartedAt; 1669 remainingTime = Math.max(0, activeDuration - elapsed); 1670 } 1671 1672 clearStoryTimer(); 1673 pauseActiveMedia(); 1674 setProgressAnimationState('paused'); 1675 } 1676 1677 function resumeStoryPlayback() { 1678 if (activeSlideHasPoll()) { 1679 resumeActiveMedia(); 1680 return; 1681 } 1682 1683 if (!isPaused) { 1684 return; 1685 } 1686 1687 if (remainingTime <= 0) { 1688 isPaused = false; 1689 playSlide(current + 1); 1690 return; 1691 } 1692 1693 isPaused = false; 1694 slideStartedAt = Date.now(); 1695 1696 clearStoryTimer(); 1697 resumeActiveMedia(); 1698 setProgressAnimationState('running'); 1699 1700 wrapper._timer = setTimeout(() => { 1701 wrapper._timer = null; 1702 playSlide(current + 1); 1703 }, remainingTime); 1704 } 1705 1706 function toggleStoryPlayback() { 1707 if (isPaused) { 1708 resumeStoryPlayback(); 1709 } else { 1710 pauseStoryPlayback(); 1711 } 1712 } 1713 1714 function activeSlideHasVideo() { 1715 return getActiveVideos().length > 0; 1716 } 1717 1718 function toggleActiveVideoPlayback() { 1719 if (!activeSlideHasVideo() || activeSlideHasPoll()) { 1720 return false; 1721 } 1722 1723 toggleStoryPlayback(); 1724 return true; 1725 } 1726 1727 wrapper._kaycoStoryControls = { 1728 hasActiveVideo: activeSlideHasVideo, 1729 toggleVideoPlayback: toggleActiveVideoPlayback 1730 }; 1731 1732 if (muteButton) { 1733 syncMuteButton(); 1734 muteButton.onclick = function (event) { 1735 event.preventDefault(); 1736 event.stopPropagation(); 1737 storyMuted = !storyMuted; 1738 applyMuteState(); 1739 }; 1740 } 1741 1742 function setBars(i, duration) { 1743 bars.forEach((bar, index) => { 1744 const span = bar.querySelector('span'); 1745
1746 span.style.animation = 'none'; 1747 span.style.transform = 'scaleX(0)'; 1748 1749 bar.classList.remove('seen'); 1750 1751 void span.offsetWidth; 1752 1753 if (index < i) { 1754 span.style.transform = 'scaleX(1)'; 1755 bar.classList.add('seen'); 1756 } 1757 1758 if (index === i) { 1759 span.style.animation = `progress ${duration}ms linear forwards`; 1760 span.style.animationPlayState = 'running'; 1761 } 1762 }); 1763 } 1764 1765 function resetVideos(targetIndex = current) { 1766 slides.forEach((s, index) => { 1767 const v = s.querySelector('video'); 1768 if (v && index !== targetIndex) { 1769 1770 v.pause(); 1771 v.currentTime = 0; 1772 } 1773 }); 1774 } 1775 1776 function playSlide(i) { 1777 1778 if (isTransitioning) return; 1779 1780 isTransitioning = true; 1781 setTimeout(() => isTransitioning = false, 80); 1782 1783 if (i >= slides.length || i < 0) { 1784 1785 if (termId) { 1786 markCategorySeen(termId, postIds); 1787 } 1788 1789 if (i >= slides.length && postIds[current]) { 1790 wrapper._kaycoStoryState.completed = true; 1791 queueStoryEvent({ 1792 eventType: 'complete', 1793 postId: postIds[current], 1794 termId, 1795 slideIndex: current, 1796 totalSlides: slides.length, 1797 duration: activeDuration || 0 1798 }); 1799 flushStoryEvents(); 1800 } 1801 1802 if (options.carousel && typeof options.onComplete === 'function') { 1803 options.onComplete(); 1804 return; 1805 } 1806 1807 loadCategory(currentCategoryIndex + 1); 1808 return; 1809 } 1810 1811 clearStoryTimer(); 1812 1813 current = i; 1814 wrapper._kaycoStoryState.current = current; 1815 wrapper._kaycoStoryState.completed = false; 1816 isPaused = false; 1817 remainingTime = 0; 1818 activeDuration = 0; 1819 slideStartedAt = 0; 1820 1821 resetVideos(i); 1822 1823 const slide = slides[current]; 1824 const video = slide.querySelector('video'); 1825 const bgVideo = slide.querySelector('.story-bg-video'); 1826 const hasPoll = slide.dataset.hasPoll === 'true' || !!slide.querySelector('.story-poll'); 1827 1828 let duration = parseInt(slide.dataset.timeout) || 3000; 1829 1830 initStoryPollCards(slide); 1831 setPollSlideMode(hasPoll); 1832 syncMuteButton(); 1833 1834 1835 // ========================= 1836 // ð¬ BACKGROUND VIDEO 1837 // ========================= 1838if (bgVideo) { 1839 1840 bgVideo.muted = storyMuted; 1841 bgVideo.currentTime = 0; 1842 1843 const startVideo = () => { 1844 return playStoryVideo(bgVideo); 1845 }; 1846 1847 const handleDuration = () => { 1848 1849 let videoDuration = bgVideo.duration; 1850 1851 1852 if ( 1853 !videoDuration || 1854 isNaN(videoDuration) || 1855 videoDuration === Infinity || 1856 videoDuration === 0 1857 ) {
1858 console.warn('â ï¸ BG duration invalid â fallback'); 1859 videoDuration = 3; 1860 } 1861 1862 const ms = videoDuration * 1000; 1863 1864 1865 if (!hasPoll) { 1866 setBars(current, ms); 1867 } 1868 1869 if (termId && postIds[current]) { 1870 markStorySeen(termId, postIds[current]); 1871 queueStoryEvent({ 1872 eventType: 'view', 1873 postId: postIds[current], 1874 termId, 1875 slideIndex: current, 1876 totalSlides: slides.length, 1877 duration: hasPoll ? 0 : ms 1878 }); 1879 } 1880 1881 wrapper._kaycoStoryState.activeDuration = hasPoll ? 0 : ms; 1882 1883 if (!hasPoll) { 1884 startStoryTimer(ms); 1885 } 1886 }; 1887 1888 if (bgVideo.readyState >= 1) { 1889 moveTo(current); 1890 startVideo().then(() => { 1891 handleDuration(); 1892 if (isPaused) { 1893 pauseStoryPlayback(); 1894 } 1895 }).catch(() => {}); 1896 } else { 1897 moveTo(current); 1898 const bgPlayPromise = startVideo(); 1899 bgVideo.onloadedmetadata = () => { 1900 bgPlayPromise.then(() => { 1901 handleDuration(); 1902 if (isPaused) { 1903 pauseStoryPlayback(); 1904 } 1905 }).catch(() => {}); 1906 }; 1907 } 1908 1909 return; 1910} 1911 1912 // ========================= 1913 // ð¬ NORMAL VIDEO 1914 // ========================= 1915if (video) { 1916 1917 1918 video.muted = storyMuted; 1919 video.currentTime = 0; 1920 1921 const startVideo = () => { 1922 return playStoryVideo(video); 1923 }; 1924 1925 const handleDuration = () => { 1926 1927 let videoDuration = video.duration; 1928 1929 1930 if ( 1931 videoDuration && 1932 !isNaN(videoDuration) && 1933 videoDuration !== Infinity && 1934 videoDuration > 0 1935 ) { 1936 duration = videoDuration * 1000; 1937 1938 1939 } else { 1940 1941 console.warn('â ï¸ Invalid video duration â fallback to dataset'); 1942 1943 duration = parseInt(slide.dataset.timeout) || 3000; 1944 } 1945 1946 if (!hasPoll) { 1947 setBars(current, duration); 1948 } 1949 1950 1951 if (termId && postIds[current]) { 1952 markStorySeen(termId, postIds[current]); 1953 queueStoryEvent({ 1954 eventType: 'view', 1955 postId: postIds[current], 1956 termId, 1957 slideIndex: current, 1958 totalSlides: slides.length, 1959 duration: hasPoll ? 0 : duration 1960 }); 1961 } 1962 1963 wrapper._kaycoStoryState.activeDuration = hasPoll ? 0 : duration; 1964 if (!hasPoll) { 1965 startStoryTimer(duration); 1966 } 1967 }; 1968 1969 // ð¥ CRITICAL: wait for metadata (THIS is why you see 3000ms) 1970 if (video.readyState >= 1) { 1971 moveTo(current); 1972 startVideo().then(() => { 1973 handleDuration(); 1974 if (isPaused) { 1975 pauseStoryPlayback(); 1976 } 1977 }).catch(() => {}); 1978 } else { 1979 moveTo(current); 1980 const playPromise = startVideo(); 1981 video.onloadedmetadata = () => { 1982 playPromise.then(() => { 1983 handleDuration(); 1984 if (isPaused) { 1985 pauseStoryPlayback(); 1986 } 1987 }).catch(() => {}); 1988 }; 1989 } 1990 1991 return; // â STOP here so dataset logic never runs 1992} else { 1993 } 1994 1995 // ========================= 1996 // ð APPLY BAR 1997 // ========================= 1998 moveTo(current); 1999 if (!hasPoll) { 2000 setBars(current, duration); 2001 } 2002 2003 2004 if (termId && postIds[current]) { 2005 markStorySeen(termId, postIds[current]); 2006 queueStoryEvent({ 2007 eventType: 'view', 2008 postId: postIds[current], 2009 termId, 2010 slideIndex: current, 2011 totalSlides: slides.length, 2012 duration: hasPoll ? 0 : duration 2013 }); 2014 } 2015 2016 wrapper._kaycoStoryState.activeDuration = hasPoll ? 0 : duration; 2017 if (!hasPoll) { 2018 startStoryTimer(duration); 2019 } 2020 } 2021 2022 function next() { 2023 playSlide(current + 1); 2024 } 2025 2026 function prev() { 2027 if (current === 0) { 2028 if (options.carousel && typeof options.onPreviousCategory === 'function') { 2029 options.onPreviousCategory(); 2030 return; 2031 } 2032 2033 if (currentCategoryIndex === 0) { 2034 return; 2035 } 2036 2037 loadCategory(currentCategoryIndex - 1, { initialSlideIndex: 'last' }); 2038 return; 2039 } 2040 2041 playSlide(current - 1); 2042 } 2043 2044 function openStoryLink(link) { 2045 const href = link.getAttribute('href'); 2046 2047 if (!href) { 2048 return; 2049 } 2050 2051 window.open(href, '_blank', 'noopener,noreferrer'); 2052 } 2053 2054 function queueInteractionEvent(event) { 2055 const postId = postIds[current]; 2056 const rect = wrapper.getBoundingClientRect(); 2057 2058 if (!postId || !rect.width || !rect.height) { 2059 return; 2060 } 2061 2062 const xPercent = ((event.clientX - rect.left) / rect.width) * 100; 2063 const yPercent = ((event.clientY - rect.top) / rect.height) * 100; 2064 const element = event.target.closest('.story-el'); 2065 2066 queueStoryEvent({ 2067 eventType: 'heatmap', 2068 postId, 2069 termId, 2070 slideIndex: current, 2071 totalSlides: slides.length, 2072 xPercent, 2073 yPercent 2074 }); 2075 2076 if (element) { 2077 queueStoryEvent({ 2078 eventType: 'click', 2079 postId, 2080 termId, 2081 slideIndex: current, 2082 totalSlides: slides.length, 2083 elementId: element.getAttribute('data-element-id') || '', 2084 elementType: element.getAttribute('data-element-type') || '', 2085 xPercent, 2086 yPercent 2087 }); 2088 } 2089 } 2090 2091 wrapper.onclick = function(e) { 2092 if (preventClickAfterTouch) { 2093 preventClickAfterTouch = false;
2094 e._kaycoStoryHandled = true; 2095 return; 2096 } 2097 2098 if ( 2099 e.target.closest('.bar') || 2100 e.target.closest('.prev-slide') || 2101 e.target.closest('.next-slide') || 2102 e.target.closest('.kosher-story-sound') 2103 ) { 2104 return; 2105 } 2106 2107 queueInteractionEvent(e); 2108 2109 const link = e.target.closest('.overlay a'); 2110 2111 if (link) { 2112 e.preventDefault(); 2113 e.stopPropagation(); 2114 openStoryLink(link); 2115 return; 2116 } 2117 2118 if (e.target.closest('.story-poll')) { 2119 return; 2120 } 2121 2122 if (activeSlideHasPoll()) { 2123 return; 2124 } 2125 2126 toggleStoryPlayback(); 2127 e._kaycoStoryHandled = true; 2128 }; 2129 2130 if (prevArrow) { 2131 prevArrow.onclick = function(e) { 2132 e.preventDefault(); 2133 e.stopPropagation(); 2134 prev(); 2135 }; 2136 } 2137 2138 if (nextArrow) { 2139 nextArrow.onclick = function(e) { 2140 e.preventDefault(); 2141 e.stopPropagation(); 2142 next(); 2143 }; 2144 } 2145 2146 wrapper.addEventListener('touchstart', function (event) { 2147 if (event.touches.length !== 1) { 2148 touchStartX = null; 2149 touchStartY = null; 2150 touchStartTarget = null; 2151 return; 2152 } 2153 2154 touchStartX = event.touches[0].clientX; 2155 touchStartY = event.touches[0].clientY; 2156 touchStartTarget = event.target; 2157 }, { passive: true }); 2158 2159 wrapper.addEventListener('touchend', function (event) { 2160 if (touchStartX === null || !touchStartTarget) { 2161 return; 2162 } 2163 2164 if ( 2165 touchStartTarget.closest('.overlay a') || 2166 touchStartTarget.closest('.story-poll') || 2167 touchStartTarget.closest('.bar') || 2168 touchStartTarget.closest('.prev-slide') || 2169 touchStartTarget.closest('.next-slide') || 2170 touchStartTarget.closest('.kosher-story-sound') 2171 ) { 2172 touchStartX = null; 2173 touchStartY = null; 2174 touchStartTarget = null; 2175 return; 2176 } 2177 2178 const changedTouch = event.changedTouches[0]; 2179 const originalTouchTarget = touchStartTarget; 2180 2181 if (!changedTouch) { 2182 touchStartX = null; 2183 touchStartY = null; 2184 touchStartTarget = null; 2185 return; 2186 } 2187 2188 const deltaX = changedTouch.clientX - touchStartX; 2189 const deltaY = changedTouch.clientY - touchStartY; 2190 const swipeThreshold = 40; 2191 const tapThreshold = 10; 2192 2193 touchStartX = null; 2194 touchStartY = null; 2195 touchStartTarget = null; 2196 2197 if (Math.abs(deltaX) <= tapThreshold && Math.abs(deltaY) <= tapThreshold) { 2198 if (activeSlideHasPoll()) { 2199 return; 2200 } 2201 preventClickAfterTouch = true; 2202 queueInteractionEvent({ 2203 clientX: changedTouch.clientX, 2204 clientY: changedTouch.clientY, 2205 target: originalTouchTarget 2206 }); 2207 toggleStoryPlayback(); 2208 return; 2209 } 2210 2211 if (Math.abs(deltaX) < swipeThreshold || Math.abs(deltaX) <= Math.abs(deltaY)) { 2212 return; 2213 } 2214 2215 preventClickAfterTouch = true; 2216 2217 if (deltaX < 0) { 2218 next(); 2219 } else { 2220 prev(); 2221 } 2222 }, { passive: true }); 2223 2224 bars.forEach((bar, i) => { 2225 bar.onclick = function(e) { 2226 e.stopPropagation(); 2227 playSlide(i); 2228 }; 2229 }); 2230 2231 playSlide(current); 2232 }; 2233 2234 function resolveInitialSlideIndex(postIds, termId, initialSlideIndex, slideCount) { 2235 if (initialSlideIndex === 'last') { 2236 return Math.max(0, slideCount - 1); 2237 } 2238 2239 if (typeof initialSlideIndex === 'number' && !Number.isNaN(initialSlideIndex)) { 2240 return Math.max(0, Math.min(slideCount - 1, initialSlideIndex)); 2241 } 2242 2243 return getStartIndex(postIds, termId); 2244 } 2245 2246});
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.