aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-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
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`