diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-28 11:09:12 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-28 11:09:12 +0200 |
| commit | 694a3832f4b57d007b79b3b21dd47b55e6ef3c42 (patch) | |
| tree | 3213225f5a26ce0014735536b9ff575f2f87e96f | |
| parent | 19c1e280cbbfc83f0c2cbadadc8e0fb692d8dd70 (diff) | |
| download | meshbay-694a3832f4b57d007b79b3b21dd47b55e6ef3c42.tar.gz | |
feat(hub): keep a show's detail modal open under the player
Closing the player lands back on the season being watched, with the
episode just started marked. A film's modal still closes on Play.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | CLAUDE.md | 1 | ||||
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 7 | ||||
| -rw-r--r-- | docs/USERGUIDE.md | 4 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/group-page.js | 2 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 14 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/video-app.js | 19 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/video-player.js | 2 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/harness/video_series_probe.py | 266 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_video_series_stays_open.py | 74 |
9 files changed, 385 insertions, 4 deletions
@@ -999,6 +999,7 @@ here are kept only where they are a rule about *editing* the code. | Flow-control worst case | `packages/meshbay-hub/tests/harness/window_leak.mjs` | | Session renewal against a hub that enforces rotation | `packages/meshbay-hub/tests/harness/session_harness.mjs` | | An invitation link opened in the real app, signed out and in | `packages/meshbay-hub/tests/harness/invite_link_probe.py` | +| A show's modal and the player, walked episode to episode | `packages/meshbay-hub/tests/harness/video_series_probe.py` | ## meshbay.org server (target state) diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 38ceec8..e6bcece 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -2606,6 +2606,13 @@ falling back to the show's. An operator can **correct a wrong automatic match**, correcting one applies to the unit rather than to a single file (for shows) or to the one file (for films), because those are the units each actually is. +**A show's detail modal outlives the episode it started.** Playing an episode +leaves the modal open under the player — on the same season, the episode just +started marked — so closing the player lands on the next episode rather than on +the grid. A film's modal closes on Play: there is nothing left in it to choose. +The player stacks above the modal by its own z-index (`.video-player-overlay`), +not by DOM order. + The filename parser is a fallback, and directory context is what bare-filename parsing cannot supply. Matching is a **scored ladder** rather than the first candidate to clear a threshold, with a year-exact rescue for a weak top hit. diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 6a86897..fc9cf40 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -302,6 +302,10 @@ needs no third-party service at all. tells you so, rather than just looking empty. - Films and shows are **one card each**, not one per file; a show expands into seasons and episodes. +- **A show stays open while you watch it.** Closing the player brings you back + to the season you were on, with the episode you just watched highlighted — + the next one is the row below it. A film's page closes when it starts, since + there is nothing to come back to. - The operator can **correct a wrong match**, and the correction applies to the whole show rather than one episode. - Streaming has **seeking, audio-track selection and subtitles**. Picture-based diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index eb012c3..0827104 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -17,7 +17,7 @@ import { lazy } from './lazy.js'; // Fetched the first time a video is played / the Settings tab is opened. const VideoPlayer = lazy(() => import('./video-player.js'), 'VideoPlayer', - html`<div class="video-overlay"><p class="page-message"><span class="spinner"></span></p></div>`); + html`<div class="video-overlay video-player-overlay"><p class="page-message"><span class="spinner"></span></p></div>`); const GroupSettingsPanel = lazy(() => import('./group-settings.js'), 'GroupSettingsPanel'); import { reportIndexPush } from './index-dock.js'; import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js'; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 8433b1a..111c03d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -1979,6 +1979,13 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } justify-content: center; } +/* The player opens on top of a show's detail modal, which stays open under it + so the next episode is one click away when the player closes. Both are + `.video-overlay`; equal values would leave the order to where each happens + to sit in the DOM, which is not a decision. Below the operator's match + search (210), which only ever opens from the modal, never over the player. */ +.video-overlay.video-player-overlay { z-index: 205; } + .video-top-bar { position: absolute; top: 0; @@ -4135,6 +4142,13 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } margin-bottom: 4px; } .video-episode-row:hover { border-color: var(--accent); color: var(--accent); } +/* The episode last started from this modal, so the one to watch next is the + row under it. A border and a tint, not a colour change of the text alone, + so it does not read as the hover state left behind. */ +.video-episode-row.last-played { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} .video-episode-row .icon { width: 14px; height: 14px; flex-shrink: 0; } .video-episode-thumb-slot { width: 64px; height: 40px; flex-shrink: 0; } .video-episode-thumb { 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 4c9f90c..9e2db1c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -619,6 +619,13 @@ function VideoDetailModal({ const [selectedSeason, setSelectedSeason] = useState(null); useEffect(() => { setSelectedSeason(defaultSeason(show)); }, [show]); + // A show's modal stays open under the player (PosterGrid's onPlay), so + // closing the player lands back here. Marking the episode just started is + // what makes "the next one" the row under the mark, rather than a row the + // reader has to find again by its number. + const [lastPlayedId, setLastPlayedId] = useState(null); + useEffect(() => { setLastPlayedId(null); }, [show]); + const showMultiSeason = Boolean(show && show.seasons.length > 1); const seasonMeta = useSeasonMeta( transportRef, confident ? meta.tmdb_id : null, selectedSeason, @@ -708,7 +715,10 @@ function VideoDetailModal({ </div> `} ${s.episodes.map((ep) => html` - <button class="video-episode-row" key=${ep.id} onClick=${() => onPlay(ep)}> + <button key=${ep.id} + class="video-episode-row ${ep.id === lastPlayedId ? 'last-played' : ''}" + aria-current=${ep.id === lastPlayedId ? 'true' : undefined} + onClick=${() => { setLastPlayedId(ep.id); onPlay(ep); }}> <${LazyTile} cls="video-episode-thumb-slot"> <${MediaThumb} thumbHash=${ep.thumb_hash} alt=${ep.display_title || ep.name} cls="video-episode-thumb" transportRef=${ep._tRef || transportRef} gekRef=${ep._gRef || gekRef} @@ -857,13 +867,18 @@ function PosterGrid({ </${LazyTile}> `; })} </div> + ${/* A show's modal is left open under the player: watching episode after + episode used to mean reopening the show and picking the season again + every time. A film has nothing left to pick, so its modal still + closes. The player stacks above it by its own z-index (style.css, + .video-player-overlay), not by DOM order. */''} ${detail && html` <${VideoDetailModal} title=${detail.title} meta=${detailMeta} repEntry=${detail.repEntry} show=${detail.show} transportRef=${detailTRef} gekRef=${detailGRef} isNodeAdmin=${isNodeAdmin} tmdbEnabled=${tmdbEnabled} onClose=${() => setDetail(null)} - onPlay=${(entry) => { setDetail(null); onPreview(entry); }} /> + onPlay=${(entry) => { if (!detail.show) setDetail(null); onPreview(entry); }} /> `} `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js index 6eaacc5..2b274b5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -1596,7 +1596,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { }, []); return html` - <div class="video-overlay" onClick=${(e) => { + <div class="video-overlay video-player-overlay" onClick=${(e) => { if (e.target.classList.contains('video-overlay')) onClose(); }}> <div class="video-top-bar"> diff --git a/packages/meshbay-hub/tests/harness/video_series_probe.py b/packages/meshbay-hub/tests/harness/video_series_probe.py new file mode 100644 index 0000000..f161c30 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/video_series_probe.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +""" +What is on screen after watching one episode of a show, and closing the player. + +Playing an episode used to close the show's detail modal, so the next episode +meant opening the show again and picking the season again, every time. The +modal now stays open under the player. That is a claim about three components +at once — `PosterGrid` deciding whether to close it, `GroupPage` mounting the +player beside it, and the stylesheet deciding which of two `.video-overlay`s is +on top — so this renders the shipped `GroupPage` against a stub node and walks +it as a reader would, reading back what is on the page after each step. + +A film goes through the same modal and must still close it: it has nothing left +to pick from. + + video_series_probe.py +""" + +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8761 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +FRAME = r"""<!doctype html><html><head><meta charset=utf-8> +<link rel="stylesheet" href="/style.css"></head><body> +<nav class="nav"><div class="nav-left"><a class="nav-brand" href="#/">MeshBay</a></div></nav> +<div class="layout"><main class="main"><div id="root"></div></main></div> +<script> +const ENTRIES = []; +let n = 0; +// Two seasons of three episodes, so there is a season to pick and a +// "next episode" after the one played. No thumbnails: a card with no frame to +// fetch is ready at once. +for (let s = 1; s <= 2; s++) { + for (let e = 1; e <= 3; e++) { + ENTRIES.push({ id: 'ep' + s + e, name: 'Some.Show.S0' + s + 'E0' + e + '.mkv', + display_title: 'Some Show', path: 'videos/Some Show/Season ' + s, + type: 'video', season: s, episode: e, duration: 2600, size: 1024, + added_at: 1750000000 + (++n) }); + } +} +ENTRIES.push({ id: 'film', name: 'A.Film.mkv', display_title: 'A Film', + path: 'videos/films', type: 'video', duration: 6000, size: 1024, + added_at: 1750000000 + (++n) }); + +const ACK = { + is_node_admin: false, + enabled_apps: ['video'], + tmdb_enabled: true, tmdb_language: 'en-US', + video_directories: ['videos'], music_directories: [], photo_directories: [], +}; + +window.MeshBayTransport = function () { + const self = { + connected: false, memberRole: 'member', supportsAppOps: true, + sessionKeys: null, gekRaw: null, + newNodeBundle: null, newNodeBundleRecovery: null, + async connect() { self.connected = true; return ACK; }, + async fetchIndex() { + return { entries: ENTRIES, dirs: ['videos'], + roots: [{ name: 'videos', available: true, writable: false, + removable: false }] }; + }, + // Unmatched: the modal still opens for both, and nothing here depends on + // what TMDB would have said. + async fetchMediaMeta() { return { confidence: 0 }; }, + addReconnectListener() { return () => {}; }, + close() {}, + }; + // Everything else the player asks for never answers: it sits on its + // spinner, which is all a stacking and a close need. + return new Proxy(self, { + get(target, prop) { + if (prop in target) return target[prop]; + if (typeof prop === 'string' && prop.startsWith('on')) return undefined; + if (typeof prop === 'symbol') return undefined; + return () => new Promise(() => {}); + }, + set(target, prop, value) { target[prop] = value; return true; }, + }); +}; +</script> +<script type="module"> +import { html, render } from '/vendor/htm-preact.js'; +import { initLocale } from '/i18n.js'; +import { GroupPage } from '/group-page.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => [...document.querySelectorAll(sel)]; +async function until(pred, what) { + for (let i = 0; i < 100; i++) { if (pred()) return; await sleep(50); } + throw new Error('timed out waiting for ' + what); +} + +try { localStorage.removeItem('meshbay_video_view_mode'); } catch {} + +const player = () => $('.video-player-overlay'); +// Which overlay a click in the middle of the window would reach. +const topmost = () => { + const el = document.elementFromPoint(innerWidth / 2, innerHeight / 2); + if (!el) return null; + if (el.closest('.video-player-overlay')) return 'player'; + if (el.closest('.video-detail') || el.closest('.video-overlay')) return 'detail'; + return 'page'; +}; +const state = () => ({ + detail: !!$('.video-detail'), + player: !!player(), + topmost: topmost(), + season: ($('.video-season-current') || {}).textContent?.trim() || null, + marked: $$('.video-episode-row.last-played').map( + (r) => r.querySelector('.video-episode-label').textContent.replace(/\s+/g, ' ').trim()), + rows: $$('.video-episode-row').map( + (r) => r.querySelector('.video-episode-label').textContent.replace(/\s+/g, ' ').trim()), +}); + +(async () => { + const out = {}; + try { + await initLocale(); + render(html`<${GroupPage} groupId="g1" token="t" username="me" userId="u1" + group=${{ id: 'g1', name: 'a group', owner_username: 'me', is_admin: false }} + userPrefs=${{ default_tab: 'video', media_page_size: '50' }} />`, + document.getElementById('root')); + + await until(() => $$('.video-card-title').length === 2, 'two cards'); + const card = (title) => $$('.video-card').find( + (c) => c.querySelector('.video-card-title').textContent.trim().startsWith(title)); + + // The show: open it, go to season 2, play its second episode. + card('Some Show').click(); + await until(() => $('.video-season-trigger'), 'the show modal'); + $('.video-season-trigger').click(); + await until(() => $$('.video-season-option').length === 2, 'the season menu'); + $$('.video-season-option')[1].click(); + await sleep(100); + $$('.video-episode-row')[1].click(); + await until(() => player() && player().querySelector('.video-top-bar'), 'the player'); + await sleep(100); + out.playing = state(); + + // Esc is how most people leave a player. + dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await until(() => !player(), 'the player to close on Esc'); + await sleep(100); + out.afterEscape = state(); + + // Next episode, straight from the modal, then the player's own close button. + $$('.video-episode-row')[2].click(); + await until(() => player() && player().querySelector('.video-top-bar .video-close'), + 'the player again'); + const closes = player().querySelectorAll('.video-top-bar .video-close'); + closes[closes.length - 1].click(); + await until(() => !player(), 'the player to close on its button'); + await sleep(100); + out.afterClose = state(); + + // Closing the modal itself still closes it. + $('.video-detail .video-top-bar .video-close').click(); + await sleep(100); + out.afterModalClose = state(); + + // Reopening the show starts afresh: no mark carried over from last time. + card('Some Show').click(); + await until(() => $('.video-detail'), 'the show modal again'); + await sleep(100); + out.reopened = state(); + $('.video-detail .video-top-bar .video-close').click(); + await sleep(100); + + // A film: its modal closes when it starts, as it always did. + card('A Film').click(); + await until(() => $('.video-detail .admin-btn'), 'the film modal'); + $('.video-detail .admin-btn').click(); + await until(() => player(), 'the film player'); + await sleep(100); + out.film = state(); + + parent.postMessage(out, '*'); + } catch (err) { + parent.postMessage({ ...out, error: String((err && err.stack) || err) }, '*'); + } +})(); +</script></body></html>""" + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head> +<body style="margin:0"><iframe src="/case" style="width:1100px;height:800px;border:0"></iframe> +<script> +addEventListener('message', (e) => fetch('/log', { method: 'POST', body: JSON.stringify(e.data) })); +</script></body></html>""" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + if self.path == "/log": + RECORDS.append(json.loads(self.rfile.read(length).decode())) + else: + self.rfile.read(length) + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/": + self._send(PAGE.encode(), "text/html; charset=utf-8") + elif path == "/case": + self._send(FRAME.encode(), "text/html; charset=utf-8") + elif path == "/v1/groups/g1/nodes": + self._send(b'{"nodes": [{"node_id": "n1"}]}', "application/json") + else: + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + self._send(asset.read_bytes(), + "text/css" if asset.suffix == ".css" + else "text/javascript" if asset.suffix == ".js" + else "application/octet-stream") + + +def main() -> int: + with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile: + proc = subprocess.Popen( + ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=1100,900", + f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + deadline = time.time() + 90 + while not RECORDS and time.time() < deadline: + time.sleep(0.2) + proc.terminate() + proc.wait(timeout=20) + if not RECORDS: + print("the page never reported", file=sys.stderr) + return 1 + print(json.dumps(RECORDS[0])) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/test_video_series_stays_open.py b/packages/meshbay-hub/tests/test_video_series_stays_open.py new file mode 100644 index 0000000..e0fe3f0 --- /dev/null +++ b/packages/meshbay-hub/tests/test_video_series_stays_open.py @@ -0,0 +1,74 @@ +""" +A show's detail modal stays open under the player. + +Playing an episode closed the modal, so watching the next one meant finding the +show's card again, opening it, and picking the season again — every episode. +The modal now stays where it was, on the same season, with the episode just +started marked, and the player is drawn over it. + +Measured rather than read: whether the reader lands back on the modal depends on +`PosterGrid`, on `GroupPage` mounting the player beside it, and on which of two +`.video-overlay`s the stylesheet puts on top. The probe renders the shipped +`GroupPage` against a stub node and walks it. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "video_series_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 / "video-app.js").exists(), + reason="Chrome or the SPA sources are not available") + + +@pytest.fixture(scope="module") +def walk(): + run = subprocess.run(["python3", str(HARNESS)], capture_output=True, timeout=150) + assert run.returncode == 0, run.stderr.decode()[-2000:] + out = json.loads(run.stdout.decode()) + assert "error" not in out, out["error"] + return out + + +def test_the_player_is_drawn_over_the_modal(walk): + assert walk["playing"]["detail"], "the show's modal closed when an episode started" + assert walk["playing"]["player"] + assert walk["playing"]["topmost"] == "player" + + +@pytest.mark.parametrize("step", ["afterEscape", "afterClose"]) +def test_closing_the_player_lands_back_on_the_season_being_watched(walk, step): + after = walk[step] + assert after["detail"] and not after["player"] + assert after["topmost"] == "detail" + assert after["season"] == walk["playing"]["season"], "the season picked was lost" + assert after["rows"] == walk["playing"]["rows"] + + +def test_the_episode_just_started_is_marked(walk): + rows = walk["playing"]["rows"] + assert walk["playing"]["marked"] == [rows[1]] + assert walk["afterEscape"]["marked"] == [rows[1]] + # Starting the next one from the modal moves the mark with it. + assert walk["afterClose"]["marked"] == [rows[2]] + + +def test_the_modal_still_closes_and_reopens_clean(walk): + assert not walk["afterModalClose"]["detail"] + reopened = walk["reopened"] + assert reopened["detail"] + assert reopened["marked"] == [], "a mark from the last visit carried over" + assert reopened["season"] != walk["playing"]["season"], ( + "a reopened show starts on its default season, as it always did") + + +def test_a_film_still_closes_its_modal(walk): + """Nothing left to pick once a film starts, so nothing to come back to.""" + assert walk["film"]["player"] + assert not walk["film"]["detail"] + assert walk["film"]["topmost"] == "player" |