summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-02 03:13:53 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-02 03:23:46 +0200
commit12b6dc4dd3e009f2e844d87181800aa12d07a3a6 (patch)
treec118ff1efee9f38de7579d4cb75ec6f4114e27d9 /packages
parentf7917bdde37fe089485bb2b65c5504315dcc9c56 (diff)
downloadmeshbay-12b6dc4dd3e009f2e844d87181800aa12d07a3a6.tar.gz
fix(hub): steady the show detail modal, and give a series its director
Opening a different season of the same show moved everything under the synopsis, which is where the season control and the episode list are, so the thing just clicked was no longer under the pointer. - The synopsis is exactly three lines for a multi-season show, with a "read more" link floated into the third line box (-webkit-line-clamp only ever puts its ellipsis at the end of the last line and leaves no room after it). Clamped from above and pinned from below to the same number: a constant, not a range — a season summary runs two lines and the next one twelve, and a band still reads as a jump. Whether three lines is all of it depends on the modal's width, so it is measured in the browser and re-measured on a resize. - The cast is clamped to two lines. - SeasonMenu replaces SeasonTabs: the tab row scrolled sideways once a show had more seasons than fit, which is close to unusable on a phone. One trigger reading "Season 5 · 1997" and a menu of every season with its episode count, one row high whatever the season count. - media_meta_resp.director was filled from the credits crew's job == "Director", a movie shape. TMDB's aggregate tv_credits crew is routinely empty and never carries that job, so every show answered null and the modal dropped the line. It now comes from created_by on the show details. Cached show metadata keeps its null until TMDB_META_TTL_SECS expires or an operator re-matches. The facts line is joined rather than concatenated (a title with no rating used to open with " · ") and carries the show's own year next to the director; the selected season's air year moved onto the picker. test_video_detail_measured.py asserts rectangles through layout_probe.py, not declarations: the picker's offset inside its own modal body is the same pixel either way, the synopsis and cast heights, where the read-more link lands, and the open menu at 320 px. Each measured block sits in a whole-pixel-height container, or two identical layouts an eighth of a pixel apart round to tops one pixel apart. test_tmdb_show_director.py covers the credit. docs/mediacenter.md §10.4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css132
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js155
-rw-r--r--packages/meshbay-hub/tests/test_video_detail_measured.py280
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py10
-rw-r--r--packages/meshbay-node/tests/test_tmdb_show_director.py101
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 &middot; TMDB #0000</p>
+ <div class="video-overview-wrap">
+ <p class="{overview_cls}">{toggle}{synopsis}</p>
+ </div>
+ <p class="video-detail-facts">
+ &#9733; 8.4 &middot; Mystery, Drama &middot; 1993 &middot; 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&hellip;</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 &middot; 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 &middot; 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"}]