The iframe is created on first hover and destroyed on leave, so
the page never pays the YouTube embed cost unless someone engages.
============================================================ */
(function () {
const box = document.getElementById('featureVideoBox');
if (!box) return;
const videoId = box.getAttribute('data-video-id');
const frameWrap = box.querySelector('.rb-feature-video-frame');
if (!videoId || !frameWrap) return;
let iframeEl = null;
let leaveTimer = null;
function play() {
clearTimeout(leaveTimer);
if (!iframeEl) {
iframeEl = document.createElement('iframe');
iframeEl.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&mute=1&loop=1&playlist=${videoId}&controls=0&modestbranding=1&rel=0`;
iframeEl.allow = 'autoplay; encrypted-media';
iframeEl.title = 'Featured video preview';
frameWrap.appendChild(iframeEl);
}
box.classList.add('is-playing');
}
function stop() {
box.classList.remove('is-playing');
// Small delay so a quick mouse-out/in doesn't rebuild the iframe.
leaveTimer = setTimeout(() => {
if (iframeEl) {
iframeEl.remove();
iframeEl = null;
}
}, 200);
}
box.addEventListener('mouseenter', play);
box.addEventListener('mouseleave', stop);
// Touch devices have no hover — first tap previews, the link works after.
box.addEventListener('touchstart', function (e) {
if (!iframeEl) {
e.preventDefault();
play();
}
}, { passive: false });
})();
/* ============================================================
REELS / SHORTS CAROUSEL
------------------------------------------------------------
Expects markup:
...
Card widths come from CSS (4 / 3 / 1 per breakpoint); this only
measures and translates. Steps are one card at a time.
============================================================ */
(function () {
const section = document.querySelector('.rb-reels');
if (!section) return;
const outer = section.querySelector('.rb-carousel-outer');
const track = document.getElementById('reelsTrack');
const dotWrap = document.getElementById('reelsDotContainer');
const prevBtn = document.getElementById('reelsPrevBtn');
const nextBtn = document.getElementById('reelsNextBtn');
if (!outer || !track || !dotWrap || !prevBtn || !nextBtn) return;
const cards = Array.from(track.querySelectorAll('.rb-reel-card'));
const TOTAL = cards.length;
if (!TOTAL) return;
let currentIndex = 0;
function outerWidth() {
// Fractional width — offsetWidth rounds, and the lost sub-pixels
// are what clip the last card.
return outer.getBoundingClientRect().width;
}
function getVisible() {
const w = outerWidth();
if (w <= 560) return 1;
if (w <= 1024) return 3;
return 4;
}
function getGap() {
// Read the real gap from CSS so the JS can't drift out of sync
// with the media queries.
return parseFloat(getComputedStyle(track).gap) || 22;
}
function cardWidth() {
return cards[0] ? cards[0].getBoundingClientRect().width : 0;
}
function totalSteps() {
return Math.max(0, TOTAL - getVisible());
}
function buildDots() {
dotWrap.innerHTML = '';
// Nothing to page through — hide the controls entirely rather than
// showing one dead dot and two greyed arrows.
if (totalSteps() === 0) {
dotWrap.style.display = 'none';
prevBtn.parentElement.style.visibility = 'hidden';
return;
}
dotWrap.style.display = '';
prevBtn.parentElement.style.visibility = '';
for (let i = 0; i <= totalSteps(); i++) {
const d = document.createElement('div');
d.className = 'rb-dot' + (i === currentIndex ? ' active' : '');
d.addEventListener('click', () => goTo(i));
dotWrap.appendChild(d);
}
}
function updateDots() {
dotWrap.querySelectorAll('.rb-dot').forEach((d, i) => {
d.classList.toggle('active', i === currentIndex);
});
}
function applyTransform() {
const offset = currentIndex * (cardWidth() + getGap());
track.style.transform = `translate3d(-${offset}px, 0, 0)`;
}
function goTo(idx) {
currentIndex = Math.max(0, Math.min(idx, totalSteps()));
applyTransform();
updateDots();
prevBtn.classList.toggle('disabled', currentIndex === 0);
nextBtn.classList.toggle('disabled', currentIndex >= totalSteps());
}
function init() {
currentIndex = Math.min(currentIndex, totalSteps());
buildDots();
applyTransform();
prevBtn.classList.toggle('disabled', currentIndex === 0);
nextBtn.classList.toggle('disabled', currentIndex >= totalSteps());
}
prevBtn.addEventListener('click', () => goTo(currentIndex - 1));
nextBtn.addEventListener('click', () => goTo(currentIndex + 1));
// Touch / swipe support
let touchStartX = 0;
outer.addEventListener('touchstart', e => { touchStartX = e.touches[0].clientX; }, { passive: true });
outer.addEventListener('touchend', e => {
const dx = touchStartX - e.changedTouches[0].clientX;
if (Math.abs(dx) > 40) dx > 0 ? goTo(currentIndex + 1) : goTo(currentIndex - 1);
}, { passive: true });
// Debounced resize
let resizeTimer;
const reflow = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(init, 80);
};
window.addEventListener('resize', reflow);
// The container can change width without the window doing so
// (scrollbar appearing, embedded in a themed page). Track it directly.
if (window.ResizeObserver) new ResizeObserver(reflow).observe(outer);
init();
// Re-measure once images/fonts have settled so widths are correct.
window.addEventListener('load', init);
})();
/* ============================================================
BREAKING NEWS MARQUEE
------------------------------------------------------------
Clones the authored items until the track is at least twice the
viewport width, then animates translateX(0 → -50%). Because the
second half is an exact copy of the first, the loop point is
invisible. Duration is derived from width so speed stays constant
(data-speed = px per second) no matter how many headlines exist.
Class-scoped, so multiple rails on one page each work.
============================================================ */
(function () {
function setupTicker(rail) {
const viewport = rail.querySelector('.rb-ticker-viewport');
const track = rail.querySelector('.rb-ticker-track');
if (!viewport || !track) return;
const SPEED = parseFloat(rail.getAttribute('data-speed')) || 70; // px/sec
const original = Array.from(track.children);
if (!original.length) return;
// Keep a pristine copy of the authored markup to rebuild from on resize.
const seed = original.map(n => n.cloneNode(true));
function measure() {
return Array.from(track.children)
.reduce((w, el) => w + el.getBoundingClientRect().width, 0);
}
function build() {
track.style.animation = 'none';
track.innerHTML = '';
seed.forEach(n => track.appendChild(n.cloneNode(true)));
const viewportWidth = viewport.getBoundingClientRect().width;
let setWidth = measure();
if (!setWidth) return;
// Repeat the authored set until one "half" covers the rail,
// so there is never a visible gap mid-scroll.
let guard = 0;
while (setWidth < viewportWidth && guard < 30) {
seed.forEach(n => track.appendChild(n.cloneNode(true)));
setWidth = measure();
guard++;
}
// Duplicate the whole thing once — this is what makes -50% seamless.
const half = Array.from(track.children).map(n => n.cloneNode(true));
half.forEach(n => track.appendChild(n));
const duration = setWidth / SPEED;
// Force reflow so the restarted animation picks up the new duration.
void track.offsetWidth;
track.style.animation =
`rb-ticker-scroll ${duration}s linear infinite`;
}
// Pause while the tab is hidden — no point burning frames off-screen.
document.addEventListener('visibilitychange', function () {
track.style.animationPlayState = document.hidden ? 'paused' : 'running';
});
let resizeTimer;
const reflow = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(build, 120);
};
window.addEventListener('resize', reflow);
if (window.ResizeObserver) new ResizeObserver(reflow).observe(viewport);
build();
// Fonts change text width — re-measure once they've loaded.
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(build);
}
window.addEventListener('load', build);
}
function boot() {
document.querySelectorAll('.rb-ticker').forEach(setupTicker);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();