diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/video-app.js | 155 |
1 files changed, 137 insertions, 18 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index 143cf4a..c709480 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -389,18 +389,129 @@ function PosterCard({ `; } -// ── season tab bar (docs/mediacenter.md §5.4's fix for a mis-scoped overview) ─ +// ── the synopsis, at a fixed height ────────────────────────────────────────── +// +// Clamped to three lines, the third cut short by the "read more" link floated +// into it — the float is what shortens that one line box, which is why the +// link is written *before* the text and the CSS reserves the two lines above +// it with a zero-width float of its own. There is no way to ask for this in +// `-webkit-line-clamp`, which only ever puts its ellipsis at the end of the +// last line. +// +// `reserve` pins the same three lines from below, so a multi-season show's +// synopsis is a constant height rather than a range: TMDB writes two lines +// for one season and twelve for the next, and the season menu underneath has +// to stay put across that. +// +// Whether three lines is all of it depends on how wide the modal is, so it is +// measured rather than counted, and re-measured on a resize. + +function OverviewText({ text, reserve }) { + const [expanded, setExpanded] = useState(false); + const [overflows, setOverflows] = useState(false); + const ref = useRef(null); + + // A different season is a different synopsis: re-collapse, or a long + // season read expanded leaves the next one's two lines expanded too. + useEffect(() => { setExpanded(false); }, [text]); + + // Only the clamped element can be measured — expanded, scrollHeight and + // clientHeight agree and the "show less" link would remove itself. + useEffect(() => { + if (expanded) return undefined; + const measure = () => { + const el = ref.current; + if (el) setOverflows(el.scrollHeight > el.clientHeight + 1); + }; + measure(); + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + }, [text, expanded]); -function SeasonTabs({ seasons, selected, onSelect }) { + const showToggle = !expanded && overflows; + const cls = ['video-detail-overview', + expanded ? '' : 'clamped', + showToggle ? 'has-more' : '', + reserve && !expanded ? 'reserved' : ''].filter(Boolean).join(' '); return html` - <div class="video-season-tabs"> - ${seasons.map((s) => html` - <button key=${s.season} - class="video-season-tab ${selected === s.season ? 'active' : ''}" - onClick=${() => onSelect(s.season)}> - ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })} - </button> - `)} + <div class="video-overview-wrap"> + <p class=${cls} ref=${ref}> + ${showToggle && html` + <button class="video-overview-toggle" onClick=${() => setExpanded(true)}> + … ${t('video.read_more')} + </button> + `} + ${text} + </p> + ${expanded && html` + <button class="video-overview-toggle standalone" + onClick=${() => setExpanded(false)}>${t('video.read_less')}</button> + `} + </div> + `; +} + +// ── season picker (docs/mediacenter.md §5.4's fix for a mis-scoped overview) ─ +// +// A row of tabs, which this was, scrolls horizontally once a show has more +// seasons than fit — a scrollbar nobody finds, hiding the seasons that matter +// most on the narrowest screens. One trigger and a menu is a fixed height +// whether the show ran three seasons or twenty-five, which is also what keeps +// the episode list below from moving when the season changes. + +function SeasonMenu({ seasons, selected, selectedYear, onSelect }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return undefined; + // Same shape as app.js's UserMenu: the trigger's own click is inside + // `ref`, so toggling still works with this listener on the document. + const close = (e) => { + if (ref.current && !ref.current.contains(e.target)) setOpen(false); + }; + // Esc closes the menu and stops there — the detail modal behind it must + // not take the same keystroke as "close the modal". + const onKey = (e) => { + if (e.key !== 'Escape') return; + e.stopPropagation(); + setOpen(false); + }; + document.addEventListener('click', close); + document.addEventListener('keydown', onKey, true); + return () => { + document.removeEventListener('click', close); + document.removeEventListener('keydown', onKey, true); + }; + }, [open]); + + const label = (n) => (n === 0 ? t('video.specials') : t('video.season_n', { n })); + + return html` + <div class="video-season-menu" ref=${ref}> + <button class="video-season-trigger" aria-haspopup="listbox" + aria-expanded=${open ? 'true' : 'false'} + onClick=${() => setOpen((o) => !o)}> + <span class="video-season-current"> + ${label(selected)}${selectedYear ? ` · ${selectedYear}` : ''} + </span> + <${Icon} name="chevron" cls="video-season-caret ${open ? 'flip' : ''}" /> + </button> + ${open && html` + <div class="video-season-options" role="listbox"> + ${seasons.map((s) => html` + <button key=${s.season} role="option" + aria-selected=${s.season === selected ? 'true' : 'false'} + class="video-season-option ${s.season === selected ? 'active' : ''}" + onClick=${() => { onSelect(s.season); setOpen(false); }}> + <span class="video-season-option-name">${label(s.season)}</span> + <span class="video-season-option-count"> + ${t('video.n_episodes', { n: s.episodes.length })} + </span> + </button> + `)} + </div> + `} </div> `; } @@ -549,6 +660,17 @@ function VideoDetailModal({ showMultiSeason && Boolean(confident) && selectedSeason != null); const seasonConfident = showMultiSeason && seasonMeta && seasonMeta.confidence; + // One line, joined rather than concatenated with leading separators: a + // title TMDB has no rating for used to open the line with " · ". + const facts = !confident ? '' : [ + meta.vote_average ? `★ ${meta.vote_average.toFixed(1)}` : null, + meta.genres && meta.genres.length ? meta.genres.join(', ') : null, + // The show's own year, not the selected season's — the season's air date + // rides along with the season picker, where it names what it belongs to. + yearOf(meta.first_air_date || meta.release_date) || null, + meta.director ? `${t('video.director')}: ${meta.director}` : null, + ].filter(Boolean).join(' · '); + return html` <div class="video-overlay" onClick=${(e) => { if (e.target.classList.contains('video-overlay')) onClose(); @@ -568,13 +690,9 @@ function VideoDetailModal({ ${confident && html`<span class="video-detail-tmdbref"> · TMDB #${meta.tmdb_id}</span>`} </p> ${confident && html` - <p class="video-detail-overview">${(seasonConfident && seasonMeta.overview) || meta.overview}</p> - <p class="video-detail-facts"> - ${meta.vote_average ? `★ ${meta.vote_average.toFixed(1)}` : ''} - ${meta.genres && meta.genres.length ? ` · ${meta.genres.join(', ')}` : ''} - ${meta.director ? ` · ${t('video.director')}: ${meta.director}` : ''} - ${seasonConfident && seasonMeta.air_date ? ` · ${yearOf(seasonMeta.air_date)}` : ''} - </p> + <${OverviewText} reserve=${showMultiSeason} + text=${(seasonConfident && seasonMeta.overview) || meta.overview} /> + ${facts && html`<p class="video-detail-facts">${facts}</p>`} ${meta.cast && meta.cast.length > 0 && html` <p class="video-detail-cast"> ${meta.cast.slice(0, 6).map((c) => c.name).join(', ')} @@ -592,7 +710,8 @@ function VideoDetailModal({ </div> `} ${showMultiSeason && html` - <${SeasonTabs} seasons=${show.seasons} selected=${selectedSeason} + <${SeasonMenu} seasons=${show.seasons} selected=${selectedSeason} + selectedYear=${seasonConfident ? yearOf(seasonMeta.air_date) : ''} onSelect=${setSelectedSeason} /> `} ${!show && html` |