aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
diff options
context:
space:
mode:
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.js155
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`