// site-watch-live.jsx — Live broadcast section. // Replaces WatchNowSection when festival.streaming.live.enabled === true. // Hero: full-bleed image + YouTube embed centre-stage. // Below: compact poster strip of tonight's strand films. (function () { const LIVE_COLORS = { Fiction: { color:'#ff5c70', dark:'#3d0a12', text:'#fff' }, Documentary: { color:'#b66af5', dark:'#1e0a38', text:'#fff' }, Animation: { color:'#ffc823', dark:'#2c1d00', text:'#141318' }, Experimental: { color:'#4dd8c0', dark:'#01211d', text:'#141318' }, Awards: { color:'#ffc823', dark:'#1a1400', text:'#141318' }, }; // Extract YouTube ID from any YouTube URL function ytId(url) { if (!url) return null; const m = url.match(/(?:v=|youtu\.be\/|embed\/)([A-Za-z0-9_-]{11})/); return m ? m[1] : null; } // ── Vote confirmation modal (multiple Vote Now buttons sit close together in the rail, // so we confirm which film before actually casting anything) ────────────────── function VoteConfirmModal({ film, mode, busy, accentColor, textOn, onConfirm, onCancel }) { // Lock body scroll + pin it at the current offset while the modal is open. Without this, // mobile Safari (and some desktop edge cases) size a `position:fixed` overlay against the // *layout* viewport rather than the *visual* one once the page has been scrolled, so the // modal renders up near the document's top and the visible viewport has to be scrolled // back up to reach it. Freezing the body avoids that mismatch entirely. React.useEffect(() => { const scrollY = window.scrollY; const body = document.body; const prev = { position: body.style.position, top: body.style.top, left: body.style.left, right: body.style.right, width: body.style.width }; body.style.position = 'fixed'; body.style.top = `-${scrollY}px`; body.style.left = '0'; body.style.right = '0'; body.style.width = '100%'; return () => { body.style.position = prev.position; body.style.top = prev.top; body.style.left = prev.left; body.style.right = prev.right; body.style.width = prev.width; window.scrollTo(0, scrollY); }; }, []); return ReactDOM.createPortal(
e.stopPropagation()} style={{ background:'#1a1820', border:'1px solid rgba(240,238,233,.14)', borderRadius:0, padding:'30px 32px', maxWidth:400, width:'100%', textAlign:'center' }}>
Confirm your vote
Vote for “{film.title}”?
, document.body ); } // ── Compact poster card ─────────────────────────────────────────────────────── function PosterCard({ film, accentColor, showVote, voted, onVoteClick, navigate }) { const poster = film.poster || (film.stillsImages && film.stillsImages[0] && film.stillsImages[0].url) || ''; const title = film.title || ''; const dir = Array.isArray(film.directors) ? film.directors.map(d => d.name || d).join(', ') : (film.directors || ''); const [hov, setHov] = React.useState(false); return (
navigate && navigate('/film/' + film.id)}> {/* Poster */}
setHov(true)} onMouseLeave={() => setHov(false)}> {poster ? {title} :
} {/* Strand accent bar at bottom */}
{hov &&
}
{/* Title */}
{title}
{dir &&
{dir}
} {showVote && ( )}
); } // ── Main component ──────────────────────────────────────────────────────────── function WatchLiveSection({ navigate }) { const d = useSite(); const f = d.festival || {}; const str = f.streaming || {}; const live = str.live || {}; const items = live.items || {}; const activeKey = live.activeItem || 'Fiction'; const item = items[activeKey] || {}; const isAwards = activeKey === 'Awards'; const sc = LIVE_COLORS[activeKey] || LIVE_COLORS.Fiction; const votingOpen = !!f.votingOpen; const votingMode = f.voting?.mode || 'internal'; const [confirmFilm, setConfirmFilm] = React.useState(null); const [votingBusy, setVotingBusy] = React.useState(false); const [voteTick, setVoteTick] = React.useState(0); const handleVoteClick = fl => setConfirmFilm(fl); const confirmColor = confirmFilm ? (LIVE_COLORS[confirmFilm.strand] || sc) : sc; const [blockedVote, setBlockedVote] = React.useState(false); const handleCancelVote = () => { if (!votingBusy) setConfirmFilm(null); }; const handleConfirmVote = () => { const fl = confirmFilm; if (!fl) return; if (votingMode === 'external') { if (fl.voteLink) window.open(fl.voteLink, '_blank'); setConfirmFilm(null); return; } setVotingBusy(true); window.castVote(fl.id, 'online', fl.year).then(res => { if (res.ok) { window.markVotedLocally(fl.id); } else if (res.duplicate) { window.markVotedLocally(fl.id); setBlockedVote(true); } setVotingBusy(false); setConfirmFilm(null); setVoteTick(t => t + 1); }); }; const videoId = ytId(item.url); const heroImage = item.heroImage || ''; const title = item.title || (isAwards ? 'Paper Bird Awards Ceremony' : `${activeKey} · Live`); // Films for tonight's strand const currentYear = String(f.year || new Date().getFullYear()); const strandFilms = isAwards ? (d.films || []).filter(fl => fl.status === 'published' && String(fl.year) === currentYear) : (d.films || []).filter(fl => fl.status === 'published' && String(fl.year) === currentYear && fl.strand === activeKey ); const trackRef = React.useRef(null); const [canPrev, setCanPrev] = React.useState(false); const [canNext, setCanNext] = React.useState(true); const updateArrows = () => { const t = trackRef.current; if (!t) return; setCanPrev(t.scrollLeft > 4); setCanNext(t.scrollLeft < t.scrollWidth - t.clientWidth - 4); }; const scrollBy = dir => { trackRef.current?.scrollBy({ left: dir * 164 * 2, behavior:'smooth' }); }; return (
{/* ── Top strand accent bar ── */}
{/* ── Hero: full-bleed image + YouTube player ── */}
{/* Background image */} {heroImage ? :
} {/* Grain texture overlay */}
{/* Bottom gradient */}
{/* Content */}
{/* Live badge */}
Live Now · {isAwards ? 'Paper Bird Awards' : activeKey}
{/* YouTube player — centred */} {videoId ? (