diff options
Diffstat (limited to 'packages/meshbay-hub/src')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 45 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/video-app.js | 52 |
2 files changed, 88 insertions, 9 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index cb8cd44..c33c91a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -3025,6 +3025,31 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } display: flex; flex-direction: column; } + +/* A multi-season show: one size, one position, every season. + Everything above the episode list is already a fixed height (three lines of + synopsis, two of cast), so the last thing that could move the season picker + is the list itself — the body scrolled as a whole, and a season with twice + the episodes pushed the modal to its max height. That moved the modal as + well as its contents: `.video-overlay` centres its child, so the taller + season *started higher up the screen*, title bar and all. A constant height + settles both — centred, a box of constant height is centred in the same + place — which is why this is a `height` and not a `max-height`. Scoped to multi-season shows on purpose: a movie or a + single-season show has no season to switch to, and a fixed height would buy + it nothing but empty space. Capped at 760px so a tall desktop window does + not turn a six-episode season into a mostly empty column. */ +.video-detail.video-detail-steady { height: min(calc(100vh - 100px), 760px); } +.video-detail.video-detail-steady .video-detail-body { overflow: hidden; } +.video-detail.video-detail-steady .video-season-list { + flex: 1; + min-height: 0; + overflow-y: auto; + /* Reserved whether or not this particular season overflows: without it a + scrolling season is a scrollbar narrower than a non-scrolling one, which + re-wraps the file path above it and shifts everything below by a line — + the same jump by a different route. */ + scrollbar-gutter: stable; +} .video-detail .video-top-bar { position: static; background: var(--bg-raised); @@ -3037,6 +3062,12 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } .video-detail-body { padding: 16px 20px; overflow-y: auto; + /* A column so the episode list can be given the leftover height and scroll + inside it. Safe against the usual flex surprise — `* { margin: 0 }` at the + top of this file means there are no collapsing margins here to lose. */ + display: flex; + flex-direction: column; + min-height: 0; } /* The synopsis, and why it is not a -webkit-line-clamp. Three lines, the third ending in "… Read more" rather than at the right @@ -3262,18 +3293,22 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } .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); } +/* Fixed to the viewport, with left/width/top-or-bottom/max-height supplied by + placeSeasonPanel() in video-app.js — see the comment there. Absolutely + positioned inside the modal it was clipped by the modal's own + `overflow: hidden` whenever the seasons outran the room under the picker. */ .video-season-options { - position: absolute; - left: 0; right: 0; top: calc(100% + 4px); - z-index: 40; + position: fixed; + z-index: 240; 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); + behind it does not have to. The inline max-height narrows this further to + whatever room the trigger actually has. */ + max-height: 320px; overflow-y: auto; } .video-season-option { 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 c709480..2fa22d0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -459,8 +459,36 @@ function OverviewText({ text, reserve }) { // whether the show ran three seasons or twenty-five, which is also what keeps // the episode list below from moving when the season changes. +// Where the panel goes, measured from the trigger. It cannot simply be an +// absolutely positioned child: the modal clips (`overflow: hidden`, for its +// rounded corners), and a show with a dozen seasons opens a panel taller than +// the room left under the picker on anything but a tall window — the last +// seasons then sit outside the modal where no scroll can reach them. Fixed to +// the viewport and measured, it also flips above the trigger when that is +// where the space is. +const SEASON_PANEL_MAX = 320; +const SEASON_PANEL_MIN = 120; +const SEASON_PANEL_GAP = 12; + +function placeSeasonPanel(el) { + if (!el) return null; + const r = el.getBoundingClientRect(); + const below = window.innerHeight - r.bottom - SEASON_PANEL_GAP; + const above = r.top - SEASON_PANEL_GAP; + const down = below >= Math.min(SEASON_PANEL_MAX, above); + const room = Math.max(SEASON_PANEL_MIN, Math.min(SEASON_PANEL_MAX, down ? below : above)); + return { + left: `${r.left}px`, + width: `${r.width}px`, + top: down ? `${r.bottom + 4}px` : 'auto', + bottom: down ? 'auto' : `${window.innerHeight - r.top + 4}px`, + maxHeight: `${room}px`, + }; +} + function SeasonMenu({ seasons, selected, selectedYear, onSelect }) { const [open, setOpen] = useState(false); + const [pos, setPos] = useState(null); const ref = useRef(null); useEffect(() => { @@ -477,28 +505,44 @@ function SeasonMenu({ seasons, selected, selectedYear, onSelect }) { e.stopPropagation(); setOpen(false); }; + // Nothing scrolls under an open panel — the detail body does not scroll + // for a show (only its episode list does) and the page behind the overlay + // cannot — so a resize is the only thing that can invalidate the + // measurement taken when it opened. + const replace = () => setPos(placeSeasonPanel(ref.current)); document.addEventListener('click', close); document.addEventListener('keydown', onKey, true); + window.addEventListener('resize', replace); return () => { document.removeEventListener('click', close); document.removeEventListener('keydown', onKey, true); + window.removeEventListener('resize', replace); }; }, [open]); + // Measured in the click, not in an effect after it: an effect would render + // the panel once at the wrong place and move it on the next frame. + const toggle = useCallback(() => { + setOpen((wasOpen) => { + if (!wasOpen) setPos(placeSeasonPanel(ref.current)); + return !wasOpen; + }); + }, []); + 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)}> + onClick=${toggle}> <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"> + ${open && pos && html` + <div class="video-season-options" role="listbox" style=${pos}> ${seasons.map((s) => html` <button key=${s.season} role="option" aria-selected=${s.season === selected ? 'true' : 'false'} @@ -675,7 +719,7 @@ function VideoDetailModal({ <div class="video-overlay" onClick=${(e) => { if (e.target.classList.contains('video-overlay')) onClose(); }}> - <div class="video-detail"> + <div class="video-detail ${showMultiSeason ? 'video-detail-steady' : ''}"> <div class="video-top-bar"> <span class="video-title">${(confident && meta.title) || title}</span> <button class="video-close" onClick=${onClose} title=${t('video.close')}> |