aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
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"}]