diff options
Diffstat (limited to 'packages')
15 files changed, 667 insertions, 31 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index b99a576..f118ffe 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -228,6 +228,8 @@ export default { 'video.source_file': 'Datei: {name}', 'video.no_match': 'Keine sichere TMDB-Übereinstimmung — Dateiname wird angezeigt.', 'video.rematch_one': 'Neu zuordnen', + 'video.read_more': 'Mehr lesen', + 'video.read_less': 'Weniger anzeigen', // Musik 'music.mode_grid': 'Alben', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 76a82db..b55359b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -226,6 +226,8 @@ export default { 'video.source_file': 'File: {name}', 'video.no_match': 'No confident TMDB match — showing the filename.', 'video.rematch_one': 'Re-match', + 'video.read_more': 'Read more', + 'video.read_less': 'Show less', // Music 'music.mode_grid': 'Albums', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 14b1e0b..8e90dca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -226,6 +226,8 @@ export default { 'video.source_file': 'Archivo: {name}', 'video.no_match': 'Sin coincidencia fiable en TMDB — se muestra el nombre del archivo.', 'video.rematch_one': 'Volver a asociar', + 'video.read_more': 'Leer más', + 'video.read_less': 'Mostrar menos', // Música 'music.mode_grid': 'Álbumes', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index aba14cb..995d4b3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -227,6 +227,8 @@ export default { 'video.source_file': 'Fichier : {name}', 'video.no_match': 'Aucune correspondance TMDB fiable — nom de fichier affiché.', 'video.rematch_one': 'Relancer la recherche', + 'video.read_more': 'Lire la suite', + 'video.read_less': 'Réduire', // Musique 'music.mode_grid': 'Albums', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 19e812b..467521f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -227,6 +227,8 @@ export default { 'video.source_file': 'File: {name}', 'video.no_match': 'Nessuna corrispondenza TMDB affidabile — mostrato il nome del file.', 'video.rematch_one': 'Riassocia', + 'video.read_more': 'Leggi tutto', + 'video.read_less': 'Mostra meno', // Musica 'music.mode_grid': 'Album', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 98d978b..89e8bdd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -224,6 +224,8 @@ export default { 'video.source_file': 'ファイル: {name}', 'video.no_match': '確実なTMDB一致なし — ファイル名を表示しています。', 'video.rematch_one': '再マッチ', + 'video.read_more': '続きを読む', + 'video.read_less': '折りたたむ', // 音楽 'music.mode_grid': 'アルバム', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index d66d914..1ed0362 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -228,6 +228,8 @@ export default { 'video.source_file': 'Bestand: {name}', 'video.no_match': 'Geen betrouwbare TMDB-match — bestandsnaam wordt getoond.', 'video.rematch_one': 'Opnieuw koppelen', + 'video.read_more': 'Meer lezen', + 'video.read_less': 'Minder tonen', // Muziek 'music.mode_grid': 'Albums', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index bae3376..f71d048 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -235,6 +235,8 @@ export default { 'video.source_file': 'Plik: {name}', 'video.no_match': 'Brak pewnego dopasowania TMDB — pokazano nazwę pliku.', 'video.rematch_one': 'Dopasuj ponownie', + 'video.read_more': 'Czytaj więcej', + 'video.read_less': 'Pokaż mniej', // Muzyka 'music.mode_grid': 'Albumy', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 6825926..1919edd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -228,6 +228,8 @@ export default { 'video.source_file': 'Arquivo: {name}', 'video.no_match': 'Sem correspondência confiável no TMDB — exibindo o nome do arquivo.', 'video.rematch_one': 'Combinar de novo', + 'video.read_more': 'Ler mais', + 'video.read_less': 'Mostrar menos', // Música 'music.mode_grid': 'Álbuns', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index b4567e7..489311e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -221,6 +221,8 @@ export default { 'video.source_file': '文件:{name}', 'video.no_match': '没有可靠的 TMDB 匹配 — 显示文件名。', 'video.rematch_one': '重新匹配', + 'video.read_more': '展开全文', + 'video.read_less': '收起', // 音乐 'music.mode_grid': '专辑', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index fe01cd6..cb8cd44 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -3038,20 +3038,85 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } padding: 16px 20px; overflow-y: auto; } +/* The synopsis, and why it is not a -webkit-line-clamp. + Three lines, the third ending in "… Read more" rather than at the right + margin: line-clamp only ever puts its ellipsis at the end of the last line, + and cannot leave room for anything after it. A float can — .video-detail- + overview.clamped::before is a zero-width float two lines tall, so the + toggle floated after it (clear: right) lands on line three and shortens + that line box alone. The toggle is written before the text in video-app.js + for the same reason: a float only pushes the line boxes that follow it. + `.reserved` pins the same three lines from below for a multi-season show, + so the height is a constant and not a range: min and max are the same + number on purpose. A season whose synopsis runs two lines and one whose + synopsis runs twelve must put the season menu, and every episode under it, + at exactly the same place — anything less and the thing just clicked is no + longer under the pointer. Three lines is what makes that constant cheap: + at five, a short synopsis sat over three blank lines. + +2px absorbs the sub-pixel line height that otherwise clips the last + line's descenders. */ +.video-overview-wrap { --ov-lh: 1.5em; --ov-lines: 3; } .video-detail-overview { font-size: 0.9em; color: var(--text); line-height: 1.5; } +.video-detail-overview.clamped { + overflow: hidden; + max-height: calc(var(--ov-lh) * var(--ov-lines) + 2px); +} +/* The same number as the max-height above, deliberately: clamped from above + and pinned from below is a constant, and a constant is the whole point. */ +.video-detail-overview.clamped.reserved { + min-height: calc(var(--ov-lh) * var(--ov-lines) + 2px); +} +/* Only when there is actually something to expand. `overflow: hidden` above + makes this a block formatting context, so it *contains* the float and grows + to its height — a one-line movie synopsis with the spacer always present + would stand two lines tall for no reason. `.has-more` is set by the measure + that decides to render the toggle at all. */ +.video-detail-overview.clamped.has-more::before { + content: ''; + float: right; + width: 0; + height: calc(var(--ov-lh) * (var(--ov-lines) - 1)); +} +.video-overview-toggle { + float: right; + clear: right; + padding: 0 0 0 6px; + border: none; + background: none; + color: var(--accent); + font: inherit; + cursor: pointer; +} +.video-overview-toggle:hover { text-decoration: underline; } +.video-overview-toggle.standalone { + float: none; + display: inline-block; + padding: 0; + margin-top: 4px; + font-size: 0.9em; +} .video-detail-facts { font-size: 0.82em; color: var(--text-dim); margin-top: 6px; } +/* Two lines, ellipsised at the end of the second. A ten-name cast and a + three-name one must not give the modal two different heights — and unlike + the synopsis there is nothing to expand to, so the plain line clamp is the + right tool here. */ .video-detail-cast { font-size: 0.82em; color: var(--text-secondary); margin-top: 6px; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; } .video-detail-source { font-size: 0.78em; @@ -3169,25 +3234,66 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } .video-flat-season { padding-left: 24px; margin-bottom: 8px; } .video-flat-season .video-season-header { margin: 6px 0 4px; } -/* Season tab bar — docs/mediacenter.md §5.4's per-season overview view */ -.video-season-tabs { +/* Season picker — docs/mediacenter.md §5.4's per-season overview view. + Was a row of pills with `overflow-x: auto`: a show with a dozen seasons hid + most of them behind a horizontal scrollbar, worst on the narrow screens that + can least afford it. One trigger and a menu is one row high whatever the + season count, so nothing below it moves either. The menu is absolutely + positioned inside .video-detail-body, which scrolls — the picker sits near + the top of that body, so the panel opens over the episode list rather than + past the bottom of the scroll area. */ +.video-season-menu { position: relative; margin: 10px 0; } +.video-season-trigger { display: flex; - gap: 4px; - margin: 10px 0; - overflow-x: auto; -} -.video-season-tab { - padding: 5px 12px; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 8px 12px; border: 1px solid var(--border); - border-radius: 999px; + border-radius: 8px; background: var(--bg-surface); - color: var(--text-dim); + color: var(--text); font-size: 0.85em; - white-space: nowrap; + cursor: pointer; + text-align: left; +} +.video-season-trigger:hover { border-color: var(--accent); color: var(--accent); } +.video-season-current { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.video-season-caret { width: 14px; height: 14px; flex-shrink: 0; color: var(--text-dim); } + +.video-season-options { + position: absolute; + left: 0; right: 0; top: calc(100% + 4px); + z-index: 40; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow-lg); + padding: 4px 0; + /* Twenty-five seasons will not fit a phone: the panel scrolls, the modal + behind it does not have to. */ + max-height: min(320px, 50vh); + overflow-y: auto; +} +.video-season-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 8px 14px; + border: none; + border-radius: 0; + background: none; + color: var(--text); + font-size: 0.85em; + text-align: left; cursor: pointer; } -.video-season-tab:hover { border-color: var(--accent); color: var(--text); } -.video-season-tab.active { border-color: var(--accent); color: var(--accent); background: var(--bg-raised); } +.video-season-option:hover { background: var(--bg-raised); } +.video-season-option.active { color: var(--accent); } +.video-season-option-count { color: var(--text-dim); font-size: 0.85em; flex-shrink: 0; } /* No own margin/size: it sits in .video-admin-actions next to the plain .admin-btn "Re-match", and any extra here made the two different sizes. */ 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` diff --git a/packages/meshbay-hub/tests/test_video_detail_measured.py b/packages/meshbay-hub/tests/test_video_detail_measured.py new file mode 100644 index 0000000..e72af0f --- /dev/null +++ b/packages/meshbay-hub/tests/test_video_detail_measured.py @@ -0,0 +1,280 @@ +""" +The show detail modal, measured: the season picker must not move. + +Reported against a real library. Three faults, one complaint — opening a +different season of the same show made the whole modal jump: + + * a season with a twelve-line synopsis and one with a two-line synopsis put + the season picker, and every episode under it, ten lines apart. The + synopsis is now a constant three lines for a multi-season show — clamped + from above and pinned from below to the same number, so the picker does + not move at all. Five was tried first and left a short synopsis sitting + over three blank lines; + * the cast line was however many lines the cast was long; + * the season picker itself was a row of pills with `overflow-x: auto`, so a + show with a dozen seasons hid most of them behind a horizontal scrollbar + that is close to unusable on a phone. + +The fixes are geometric, so the assertions are rectangles rather than +declarations, in the manner `test_layout_measured.py` established: reading +`-webkit-line-clamp: 2` out of the stylesheet says nothing about where the +thing below it lands. + +The markup here is what `video-app.js`'s `OverviewText` and `SeasonMenu` +render, class for class — `clamped` always while collapsed, `has-more` only +when the text actually overflows (measured in the browser, not counted), and +`reserved` only for a multi-season show, which is the only place a season can +change underneath the picker. +""" + +import json +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "layout_probe.py" +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" + +pytestmark = pytest.mark.skipif( + shutil.which("google-chrome") is None or not (STATIC / "style.css").exists(), + reason="Chrome or the SPA stylesheet is not available") + +SHORT_SYNOPSIS = "Two teams share a city, and one of them is lying about it." + +LONG_SYNOPSIS = ( + "A courier takes a parcel across a border that closed the week before, " + "and finds the town on the other side keeping an arrangement nobody " + "there is willing to describe out loud. The season follows the four " + "households that made it, the clerk who has been filing the paperwork " + "for eleven years without reading it, and the inspector sent to find " + "out why a road that leads nowhere is resurfaced every spring. What " + "began as an accounting discrepancy turns into a question about who " + "the town has been paying, and for what, and whether the answer was " + "ever a secret or merely something no one had asked about." +) + +SHORT_CAST = "A Performer, Another Performer" +LONG_CAST = ", ".join(f"Performer Number {n}" for n in range(1, 11)) + +SEASONS = [(0, "Specials", "4 episodes"), + *((n, f"Season {n}", "22 episodes") for n in range(1, 13))] + + +def _detail(block_id, synopsis, cast, *, reserved=True, has_more=True, menu_open=False): + """One `.video-detail` as VideoDetailModal renders it for a show.""" + overview_cls = " ".join( + ["video-detail-overview", "clamped"] + + (["has-more"] if has_more else []) + + (["reserved"] if reserved else [])) + toggle = ('<button class="video-overview-toggle">… Read more</button>' + if has_more else "") + options = "".join( + f'<button class="video-season-option{" active" if n == 5 else ""}">' + f'<span class="video-season-option-name">{name}</span>' + f'<span class="video-season-option-count">{count}</span></button>' + for n, name, count in SEASONS) + panel = (f'<div class="video-season-options" role="listbox">{options}</div>' + if menu_open else "") + # Each block sits in a container of whole-pixel height. Stacked in normal + # flow they start at fractional offsets — a modal's own content height is + # not an integer number of device pixels — and two identical layouts an + # eighth of a pixel apart round to tops one pixel apart, which looks like + # a defect in the thing being measured and is not one. + return f""" + <div style="position: relative; height: 760px"> + <div id="{block_id}" class="video-detail"> + <div class="video-top-bar"> + <span class="video-title">A Show With Several Seasons</span> + <button class="video-close">x</button> + </div> + <div class="video-detail-body"> + <p class="video-detail-source">File: shows/S05/e01.mkv · TMDB #0000</p> + <div class="video-overview-wrap"> + <p class="{overview_cls}">{toggle}{synopsis}</p> + </div> + <p class="video-detail-facts"> + ★ 8.4 · Mystery, Drama · 1993 · Director: A Creator + </p> + <p class="video-detail-cast">{cast}</p> + <div class="video-admin-actions"> + <button class="admin-btn video-fix-match">Fix match…</button> + <button class="admin-btn">Re-match</button> + </div> + <div class="video-season-menu"> + <button class="video-season-trigger"> + <span class="video-season-current">Season 5 · 1997</span> + <svg class="icon video-season-caret" viewBox="0 0 24 24"></svg> + </button> + {panel} + </div> + <div class="video-season-list"> + <div class="video-season"> + <button class="video-episode-row"> + <span class="video-episode-label">S05E01 · An Episode</span> + <span class="video-episode-meta">44min 1280x720</span> + </button> + </div> + </div> + </div> + </div> + </div> + """ + + +FRAGMENT = textwrap.dedent(f""" + <!-- One line of the same text at the same size: every height below is + asserted in these, not in pixels pinned to a font stack. --> + <div id="oneline" class="video-detail"><div class="video-detail-body"> + <div class="video-overview-wrap"><p class="video-detail-overview">One</p></div> + <p class="video-detail-cast">{SHORT_CAST}</p> + </div></div> + """) + \ + _detail("long", LONG_SYNOPSIS, LONG_CAST) + \ + _detail("short", SHORT_SYNOPSIS, LONG_CAST, has_more=False) + \ + _detail("movie", SHORT_SYNOPSIS, SHORT_CAST, reserved=False, has_more=False) + \ + _detail("open", LONG_SYNOPSIS, LONG_CAST, menu_open=True) + +WIDTHS = [320, 360, 412, 768, 1024] +SELECTORS = [ + "#oneline .video-detail-overview", "#oneline .video-detail-cast", + "#long .video-detail-overview", "#long .video-detail-cast", + "#long .video-season-menu", "#long .video-detail-body", + "#long .video-overview-toggle", + "#short .video-detail-overview", + "#short .video-season-menu", "#short .video-detail-body", + "#movie .video-detail-overview", + "#open .video-season-options", "#open.video-detail", +] + + +@pytest.fixture(scope="module") +def measured(tmp_path_factory): + """One browser for every width — launching one apiece cost three minutes.""" + fragment = tmp_path_factory.mktemp("videodetail") / "fragment.html" + fragment.write_text(FRAGMENT) + proc = subprocess.run( + ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS), + str(fragment), *SELECTORS], + capture_output=True, text=True, timeout=180) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + out = json.loads(proc.stdout) + assert "error" not in out, f"no measurement: {out}" + return out + + +def _box(measured, width, selector): + box = measured[str(width)]["boxes"][selector] + assert box is not None, f"{selector} did not render at {width} px" + return box + + +def _line(measured, width): + """One line of synopsis, at this width.""" + return _box(measured, width, "#oneline .video-detail-overview")["height"] + + +@pytest.mark.parametrize("width", WIDTHS) +def test_the_season_picker_does_not_move_with_the_synopsis(measured, width): + """The complaint itself. + + The two blocks differ in one thing only, which is the one thing that + changes when a season is selected: the synopsis. Everything above the + picker is show-level and identical between them. One is a single line, the + other twelve — the widest gap a season change can produce — and the answer + must be the same pixel, not a narrow band. Measured from each modal's own + body, since the blocks are stacked down the page. + """ + def offset(block): + return (_box(measured, width, f"#{block} .video-season-menu")["top"] + - _box(measured, width, f"#{block} .video-detail-body")["top"]) + + long_off, short_off = offset("long"), offset("short") + assert long_off == short_off, ( + f"at {width} px the season picker sits {long_off} px down for a long " + f"synopsis and {short_off} px down for a short one — a " + f"{abs(long_off - short_off)} px jump on every season change") + + +@pytest.mark.parametrize("width", WIDTHS) +def test_the_synopsis_is_three_lines_whatever_it_says(measured, width): + """Clamped from above and pinned from below to the same three lines.""" + line = _line(measured, width) + for block in ("long", "short"): + h = _box(measured, width, f"#{block} .video-detail-overview")["height"] + assert abs(h - 3 * line) <= 4, ( + f"the {block} synopsis is {h} px at {width} px, and one line is " + f"{line} px — that is {h / line:.1f} lines, not three") + + +@pytest.mark.parametrize("width", WIDTHS) +def test_a_read_more_link_lands_on_the_last_line(measured, width): + """The float trick, as geometry. + + `-webkit-line-clamp` cannot leave room after its ellipsis, so the toggle is + floated into the last line box instead. If the float spacer is dropped the + link goes to line one; if `clear: right` is dropped it shares a line with + the text somewhere in the middle. + """ + line = _line(measured, width) + top = _box(measured, width, "#long .video-detail-overview")["top"] + link = _box(measured, width, "#long .video-overview-toggle") + offset = link["top"] - top + assert 2 * line - 4 <= offset <= 3 * line, ( + f"the read-more link starts {offset} px into a three-line box of " + f"{line} px lines at {width} px — it is not on the third line") + assert link["offRight"] == 0, ( + f"{link['offRight']} px of the read-more link is off a {width} px screen") + + +@pytest.mark.parametrize("width", WIDTHS) +def test_a_short_synopsis_with_nothing_to_expand_stays_short(measured, width): + """The spacer is scoped to `.has-more` for a reason. + + `overflow: hidden` makes the paragraph a block formatting context, so it + contains the four-line-tall float and grows to it. A movie or a + single-season show — nothing below it can change — must keep its natural + height, with no floor under it either. + """ + line = _line(measured, width) + h = _box(measured, width, "#movie .video-detail-overview")["height"] + assert h <= 2 * line + 4, ( + f"a two-line synopsis with no season under it occupies {h} px at " + f"{width} px, which is {h / line:.1f} lines of mostly nothing") + + +@pytest.mark.parametrize("width", WIDTHS) +def test_the_cast_is_two_lines_however_long_the_cast_is(measured, width): + """Ten names and two names must give the modal the same height.""" + ref = _box(measured, width, "#oneline .video-detail-cast")["height"] + long_cast = _box(measured, width, "#long .video-detail-cast")["height"] + assert long_cast <= 2 * ref + 4, ( + f"a ten-name cast takes {long_cast} px at {width} px against {ref} px " + f"for one line — {long_cast / ref:.1f} lines, and the clamp is two") + + +@pytest.mark.parametrize("width", WIDTHS) +def test_the_open_season_menu_stays_inside_the_modal(measured, width): + """It replaced a horizontally scrolling pill row; it must not overflow + sideways in turn, and thirteen seasons must not run off the bottom.""" + panel = _box(measured, width, "#open .video-season-options") + modal = _box(measured, width, "#open.video-detail") + assert panel["offLeft"] == 0 and panel["offRight"] == 0, ( + f"the season menu hangs {panel['offLeft']} px off the left and " + f"{panel['offRight']} px off the right of a {width} px screen") + assert panel["left"] >= modal["left"] and panel["right"] <= modal["right"], ( + f"the season menu ({panel['left']}..{panel['right']}) is wider than " + f"the modal it belongs to ({modal['left']}..{modal['right']})") + assert panel["height"] <= 320, ( + f"thirteen seasons make a {panel['height']} px menu — it is meant to " + "scroll inside itself, not to become the page") + + +@pytest.mark.parametrize("width", [320, 360, 412]) +def test_the_page_does_not_scroll_sideways(measured, width): + """The pill row's own scrollbar is gone; nothing may replace it.""" + r = measured[str(width)] + assert r["docScrollW"] <= r["viewport"]["w"], ( + f"the document scrolls to {r['docScrollW']} px on a {width} px screen") diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 64ba75c..1f069e7 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -3404,6 +3404,16 @@ class WebRTCPeerSession: director = next( (c.get("name") for c in (credits or {}).get("crew", []) if c.get("job") == "Director"), None) + else: + # A series has no single director, and `tv_credits`' crew is the + # aggregate one — routinely empty, and never a "Director" job. + # TMDB models the equivalent credit as `created_by` on the show + # itself, which is what its own page shows; several creators are + # ordinary, and they read as one line in the detail modal. + director = ", ".join( + name for name in + (c.get("name") for c in details.get("created_by") or []) + if name) or None runtime = details.get("runtime") if runtime is None and media_type == "tv": episode_run_times = details.get("episode_run_time") or [] diff --git a/packages/meshbay-node/tests/test_tmdb_show_director.py b/packages/meshbay-node/tests/test_tmdb_show_director.py new file mode 100644 index 0000000..cb31c2c --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb_show_director.py @@ -0,0 +1,101 @@ +""" +A series has a director credit in the detail modal too. + +`media_meta_resp.director` was filled from the credits crew's `job == +"Director"`, which is a movie shape. TMDB's aggregate `tv_credits` crew is +routinely empty and never carries that job, so every show answered with +`director: null` and the detail modal simply dropped the line — reported as +"I can't see the director of a series". + +What TMDB models for a show, and what its own page shows, is `created_by` on +the show details. Several creators is ordinary, so they join into one line. +""" + +import pytest + +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +pytestmark = pytest.mark.asyncio + + +class FakeTmdbClient: + """Details and credits, with no language fallback to get in the way.""" + + def __init__(self, details: dict, credits: dict | None = None): + self._details = details + self._credits = credits or {"cast": [], "crew": []} + + async def movie_details(self, tmdb_id, language=None): + return self._details + + async def tv_details(self, tmdb_id, language=None): + return self._details + + async def movie_credits(self, tmdb_id): + return self._credits + + async def tv_credits(self, tmdb_id): + return self._credits + + +def _details(**extra): + """Enough fields that the English per-field fallback never fires.""" + return {"name": "Some Show", "overview": "A synopsis.", + "poster_path": "/p.jpg", "genres": [{"name": "Drama"}], + "first_air_date": "1993-09-10", **extra} + + +async def test_a_show_reports_its_creator_as_the_director(): + client = FakeTmdbClient(_details(created_by=[{"name": "A Creator"}])) + + meta = await WebRTCPeerSession._tmdb_build_meta(client, "4087", "tv", {"id": 4087}) + + assert meta["director"] == "A Creator" + + +async def test_several_creators_read_as_one_line(): + client = FakeTmdbClient(_details( + created_by=[{"name": "A Creator"}, {"name": "Another Creator"}])) + + meta = await WebRTCPeerSession._tmdb_build_meta(client, "4087", "tv", {"id": 4087}) + + assert meta["director"] == "A Creator, Another Creator" + + +@pytest.mark.parametrize("created_by", [None, [], [{"name": ""}], [{}]]) +async def test_a_show_with_no_creator_credit_stays_none(created_by): + """None, not "" — the modal drops the line rather than printing a label + with nothing after it.""" + client = FakeTmdbClient(_details(created_by=created_by)) + + meta = await WebRTCPeerSession._tmdb_build_meta(client, "4087", "tv", {"id": 4087}) + + assert meta["director"] is None + + +async def test_an_empty_tv_crew_no_longer_decides_the_answer(): + """The old shape, kept as a regression: a show whose aggregate crew has a + Director entry is still described by its creators, and one whose crew is + empty — the usual case — is no longer left blank.""" + client = FakeTmdbClient( + _details(created_by=[{"name": "A Creator"}]), + credits={"cast": [], "crew": [{"job": "Director", "name": "An Episode Director"}]}) + + meta = await WebRTCPeerSession._tmdb_build_meta(client, "4087", "tv", {"id": 4087}) + + assert meta["director"] == "A Creator" + + +async def test_a_movie_still_takes_its_director_from_the_crew(): + """`created_by` does not exist on a movie; nothing about that path moves.""" + client = FakeTmdbClient( + {"title": "Some Film", "overview": "A synopsis.", "poster_path": "/p.jpg", + "genres": [{"name": "Thriller"}], "release_date": "2014-02-01"}, + credits={"cast": [{"name": "A Performer", "character": "Someone"}], + "crew": [{"job": "Editor", "name": "An Editor"}, + {"job": "Director", "name": "A Director"}]}) + + meta = await WebRTCPeerSession._tmdb_build_meta(client, "418517", "movie", {"id": 418517}) + + assert meta["director"] == "A Director" + assert meta["cast"] == [{"name": "A Performer", "character": "Someone"}] |