diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-02 03:13:53 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-02 03:23:46 +0200 |
| commit | 12b6dc4dd3e009f2e844d87181800aa12d07a3a6 (patch) | |
| tree | c118ff1efee9f38de7579d4cb75ec6f4114e27d9 /packages/meshbay-hub/src | |
| parent | f7917bdde37fe089485bb2b65c5504315dcc9c56 (diff) | |
| download | meshbay-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/meshbay-hub/src')
12 files changed, 276 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` |