// 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}”?
{ e.currentTarget.style.borderColor = 'rgba(240,238,233,.7)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(240,238,233,.3)'; }}>
Cancel
{ if (!busy) e.currentTarget.style.filter = 'brightness(1.12)'; }}
onMouseLeave={e => { e.currentTarget.style.filter = 'none'; }}>
{busy ? 'Voting…' : 'Yes, vote'}
,
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
?
:
}
{/* Strand accent bar at bottom */}
{hov &&
}
{/* Title */}
{title}
{dir &&
{dir}
}
{showVote && (
{ e.stopPropagation(); if (!voted) onVoteClick(film); }}
disabled={voted}
style={{ marginTop:10, padding:'6px 14px', fontFamily:'var(--mono)', fontSize:9,
letterSpacing:'.13em', textTransform:'uppercase',
background: voted ? 'transparent' : accentColor,
color: voted ? accentColor : (LIVE_COLORS[film.strand]?.text || '#fff'),
border: voted ? `1px solid ${accentColor}` : 'none',
cursor: voted ? 'default' : 'pointer',
transition:'opacity .15s' }}
onMouseEnter={e => { if (!voted) e.currentTarget.style.opacity='.78'; }}
onMouseLeave={e => e.currentTarget.style.opacity='1'}>
{voted ? '✓ Voted' : 'Vote Now →'}
)}
);
}
// ── 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 ? (
VIDEO
) : (
)}
{/* Bottom row: title + CTA */}
navigate && navigate('/programme')}
style={{ fontFamily:'var(--mono)', fontSize:10, letterSpacing:'.16em',
textTransform:'uppercase', padding:'13px 26px',
background:'transparent', border:`1px solid rgba(240,238,233,.3)`,
color:'var(--paper)', cursor:'pointer', whiteSpace:'nowrap',
transition:'border-color .16s, color .16s', flexShrink:0 }}
onMouseEnter={e => { e.currentTarget.style.borderColor='var(--paper)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor='rgba(240,238,233,.3)'; }}>
View Programme →
{/* ── Tonight's films: compact poster strip ── */}
{strandFilms.length > 0 && (
{/* Poster rail header with arrows */}
{isAwards ? 'All films · Sixth Edition' : `Screening Tonight · ${activeKey}`}
{strandFilms.length} {strandFilms.length === 1 ? 'film' : 'films'}
scrollBy(-1)} disabled={!canPrev}
style={{ width:40, height:40, borderRadius:'50%', border:'1px solid rgba(240,238,233,.2)',
background:'none', color:'var(--paper)', cursor:'pointer', display:'flex',
alignItems:'center', justifyContent:'center', transition:'.16s',
opacity: canPrev ? 1 : .25 }}>
scrollBy(1)} disabled={!canNext}
style={{ width:40, height:40, borderRadius:'50%', border:'1px solid rgba(240,238,233,.2)',
background:'none', color:'var(--paper)', cursor:'pointer', display:'flex',
alignItems:'center', justifyContent:'center', transition:'.16s',
opacity: canNext ? 1 : .25 }}>
{/* Poster rail */}
{strandFilms.map(fl => {
const showVote = votingOpen && (votingMode === 'internal' ? fl.voteEnabled : fl.voteLink);
const voted = voteTick >= 0 && window.hasVotedLocally && window.hasVotedLocally(fl.id);
return (
);
})}
{/* Edge fades — separate overlay layers so opacity can transition smoothly;
CSS mask-image swaps don't animate reliably across browsers. */}
)}
{confirmFilm && (
)}
{blockedVote && window.VoteBlockedModal && (
setBlockedVote(false)} />
)}
);
}
Object.assign(window, { WatchLiveSection });
})();